Compare commits

...

18 Commits

Author SHA1 Message Date
June 971393892a v0.4.3 release 2025-09-07 20:32:16 -05:00
June 440515a3db Actually query terminals and fix terminals if we fail initialization (#103) 2025-09-06 12:02:04 -05:00
itsjunetime 690489016c fmt 2025-09-05 11:15:58 -05:00
itsjunetime bd5554db27 Update mupdf to use git dependency so it works on windows 2025-09-05 10:34:46 -05:00
itsjunetime 45409bacd0 fix CI? 2025-09-05 10:15:49 -05:00
itsjunetime 0481c14c4d fix CI 2025-09-05 08:41:38 -05:00
itsjunetime a78ea5a08c Update deps for new kittage 2025-09-05 08:31:03 -05:00
June f7eabc9af2 Require CI to run with locked flag so lockfile is always in sync (#100) 2025-08-29 09:17:14 -05:00
itsjunetime 918c192047 Add changelog entry for ctrl+scroll zooming 2025-08-20 21:59:29 -05:00
Per Hurtig 8b03329bba Add ctrl+mousewheel zoom control for fill-screen mode (#94)
* Add ctrl+mousewheel zoom control for fill-screen mode

  Enables mouse-based zooming; Uses ctrl+scroll up/down to
  increase/decrease zoom level while in fill-screen mode, with proper
  mouse capture handling.

* removed unused include
2025-08-20 21:57:55 -05:00
itsjunetime 7064be32f2 Update deps including ratatui-image 2025-08-20 09:23:49 -05:00
itsjunetime 2a03294557 Release version 0.4.2 2025-08-18 10:18:26 -05:00
Per Hurtig 7c2c6484a6 Fix macOS shared memory filename length limit (fixes issue #92) (#93)
* Fix macOS shared memory filename length limit

Shorten shared memory object names from "__tdf_kittage_{pid}_page_{rn}_{page_num}"
to "tdf_{pid}_{rn}_{page_num}" and change timestamp from nanoseconds to
milliseconds % 1M to stay under macOS's 31-character limit for shm names.

Fixes "File name too long (os error 63)" error when rendering PDFs on macOS.

On macOS, SHM_NAME_MAX: 30

* Fix macOS shared memory filename length limit

Shorten shared memory object names from "__tdf_kittage_{pid}_page_{rn}_{page_num}"
to "tdf_{pid}_{rn}_{page_num}" and change timestamp from nanoseconds to
milliseconds % 1M to stay under macOS's 31-character limit for shm names.

Fixes "File name too long (os error 63)" error when rendering PDFs on macOS.

SHM_NAME_MAX: 30
2025-08-18 10:15:19 -05:00
itsjunetime e65472e571 Add --version flag 2025-08-15 09:43:52 -05:00
itsjunetime 4fd2237b69 Specify on README to use nightly when building even though it should be detected by rust-toolchain.toml 2025-08-11 21:26:17 -05:00
itsjunetime 69fd8ec7e8 Add instructions for zooming and such to help page 2025-08-09 15:58:56 -06:00
itsjunetime ebd902e864 Mark v0.4.0 on changelog 2025-08-06 09:35:38 -06:00
June b6bc76edbb New kitty image backend (#74)
* Initial attempt at supporting new backend for kitty images

* it's almost working !!

* it almost basically works

* yaaayyyy it works

* Use github kittage

* Uhhhh various improvements from kittage and psx-shm

* Remove logging

* incorporate recovering from deleted images

* Make it work correctly with ghostty image eviction too

* fall back to stdout if shms don't work

* Make help page work again

* zooming basically does what you'd expect now

* yay zooming woohoo

* clean up top and bottom rendering

* Only allow zooming in kitty

* Add debug logging and fix cursor placement after image display

* yaaaay zooming out once you're already zoomed in and respecting kitty's limits for how big of an image to display

* mmmm maybe it's finally ready to merge...

* Update deps

* Switch around list of items on changelog

* fmt

* Small fixes to avoid panic and allow zooming back in after zooming out
2025-08-06 09:34:55 -06:00
15 changed files with 1742 additions and 691 deletions
+6 -4
View File
@@ -31,10 +31,12 @@ jobs:
- name: Install clippy and fmt
run: rustup component add clippy rustfmt
- name: Clippy
run: cargo clippy -- -D warnings
run: cargo clippy --locked -- -D warnings
- name: Tests
run: cargo test --locked
- name: Check fmt
run: cargo fmt -- --check
- name: Run tests
run: cargo test --benches -- adobe_example
- name: Run benchmarks as tests
run: cargo test --locked --benches -- adobe_example
- name: Build
run: cargo build
run: cargo build --locked
+1
View File
@@ -1 +1,2 @@
/target
debug.log
+19 -1
View File
@@ -1,7 +1,25 @@
# Unreleased
- Update ratatui(-image) dependencies
- Fix issue with some terminals hanging on startup
- Fix issues with some iterm2-backend terminals not displaying anything
- Allow using ctrl+scroll to zoom in/out while zoomed using kitty backend
- (Internal) run CI with `--locked` flag to ensure lockfile is always in-sync
# v0.4.2
- Add `--version` flag
- Fix shms not working on macos ([#93](https://github.com/itsjunetime/tdf/pull/93))
# v0.4.1
- Add instructions for using new zoom/pan features to help page
# v0.4.0
- Update to new `kittage` backend for kitty-protocol-supporting terminals (fixes many issues and improves performance significantly, see [the PR](https://github.com/itsjunetime/tdf/pull/74))
- Use new mupdf search API for slightly better performance
- Update ratatui(-image) dependencies
- Allow specification of default white and black colors for rendered pdfs
- Pause rendering every once in a while while there's a search term to enable searching across the entire document more quickly
- Fix an issue with missing search highlights
Generated
+474 -297
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "tdf-viewer"
version = "0.3.0"
version = "0.4.2"
authors = ["June Welker <junewelker@gmail.com>"]
edition = "2024"
description = "A terminal viewer for PDFs"
@@ -11,7 +11,7 @@ license = "AGPL-3.0-only"
keywords = ["pdf", "tui", "cli", "terminal"]
categories = ["command-line-utilities", "text-processing", "visualization"]
default-run = "tdf"
rust-version = "1.85"
rust-version = "1.86"
[[bin]]
name = "tdf"
@@ -29,17 +29,24 @@ ratatui = { git = "https://github.com/itsjunetime/ratatui.git" }
ratatui-image = { git = "https://github.com/itsjunetime/ratatui-image.git", branch = "vb64_on_personal", default-features = false }
# ratatui-image = { path = "./ratatui-image", default-features = false }
crossterm = { version = "0.29.0", features = ["event-stream"] }
# crossterm = { path = "../crossterm", features = ["event-stream"] }
image = { version = "0.25.1", features = ["pnm", "rayon", "png"], default-features = false }
notify = { version = "8.0.0", features = ["crossbeam-channel"] }
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
futures-util = { version = "0.3.30", default-features = false }
itertools = "*"
flume = { version = "0.11.0", default-features = false, features = ["async"] }
xflags = "0.4.0-pre.2"
mimalloc = "0.1.43"
nix = { version = "0.30.0", features = ["signal"] }
mupdf = { version = "0.5.0", default-features = false, features = ["svg", "system-fonts", "img"] }
mupdf = { git = "https://github.com/messense/mupdf-rs.git", rev = "2e0fae910fac8048c7008211fc4d3b9f5d227a07", default-features = false, features = ["svg", "system-fonts", "img"] }
rayon = { version = "*", default-features = false }
# kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate", "log"] }
kittage = { git = "https://github.com/itsjunetime/kittage.git", features = ["crossterm-tokio", "image-crate", "log"] }
memmap2 = "*"
# logging
log = "0.4.27"
flexi_logger = "0.31"
# for tracing with tokio-console
console-subscriber = { version = "0.4.0", optional = true }
@@ -57,7 +64,7 @@ epub = ["mupdf/epub"]
cbz = ["mupdf/cbz"]
[dev-dependencies]
criterion = { version = "0.6.0", features = ["async_tokio"] }
criterion = { version = "0.7.0", features = ["async_tokio"] }
cpuprofiler = "0.0.4"
[[bench]]
+1 -1
View File
@@ -25,7 +25,7 @@ If it turns out that you're missing one of these, it will fail to compile and te
1. Get the rust toolchain from [rustup.rs](https://rustup.rs)
2. Clone the repo and `cd` into it
3. Run `cargo build --release`
3. Run `cargo +nightly build --release`
The binary should then be found at `./target/release/tdf`.
+7 -2
View File
@@ -87,12 +87,15 @@ pub fn start_rendering_loop(
};
to_render_tx.send(RenderNotif::Area(main_area)).unwrap();
let cell_height_px = size.height / size.rows;
let cell_width_px = size.width / size.columns;
std::thread::spawn(move || {
start_rendering(
&str_path,
to_main_tx,
from_main_rx,
size,
cell_height_px,
cell_width_px,
tdf::PrerenderLimit::All,
black,
white
@@ -119,7 +122,9 @@ pub fn start_converting_loop(
to_main_tx,
from_main_rx,
picker,
prerender
prerender,
// just assume shms work for now, who cares
true
));
let from_converter_rx = from_converter_rx.into_stream();
+113 -23
View File
@@ -1,15 +1,59 @@
use std::{
num::{NonZeroU32, NonZeroUsize},
time::{SystemTime, UNIX_EPOCH}
};
use flume::{Receiver, SendError, Sender, TryRecvError};
use futures_util::stream::StreamExt;
use image::DynamicImage;
use itertools::Itertools;
use kittage::NumberOrId;
use ratatui::layout::Rect;
use ratatui_image::{Resize, picker::Picker, protocol::Protocol};
use ratatui_image::{
Resize,
picker::{Picker, ProtocolType},
protocol::Protocol
};
use rayon::iter::ParallelIterator;
use crate::renderer::{PageInfo, RenderError, fill_default};
use crate::{
renderer::{PageInfo, RenderError, fill_default},
skip::InterleavedAroundWithMax
};
#[derive(Debug)]
pub enum MaybeTransferred {
NotYet(kittage::image::Image<'static>),
Transferred(kittage::ImageId)
}
#[derive(Debug)]
pub enum ConvertedImage {
Generic(Protocol),
Kitty {
img: MaybeTransferred,
cell_w: u16,
cell_h: u16
}
}
impl ConvertedImage {
pub fn w_h(&self) -> (u16, u16) {
match self {
Self::Generic(prot) => {
let a = prot.area();
(a.width, a.height)
}
Self::Kitty {
img: _,
cell_w,
cell_h
} => (*cell_w, *cell_h)
}
}
}
pub struct ConvertedPage {
pub page: Protocol,
pub page: ConvertedImage,
pub num: usize,
pub num_results: usize
}
@@ -24,17 +68,21 @@ pub async fn run_conversion_loop(
sender: Sender<Result<ConvertedPage, RenderError>>,
receiver: Receiver<ConverterMsg>,
mut picker: Picker,
prerender: usize
prerender: usize,
shms_work: bool
) -> Result<(), SendError<Result<ConvertedPage, RenderError>>> {
let mut images = vec![];
let mut page: usize = 0;
let pid = std::process::id();
fn next_page(
images: &mut [Option<PageInfo>],
picker: &mut Picker,
page: usize,
iteration: &mut usize,
prerender: usize
prerender: usize,
pid: u32,
shms_work: bool
) -> Result<Option<ConvertedPage>, RenderError> {
if images.is_empty() || *iteration >= prerender {
return Ok(None);
@@ -45,13 +93,19 @@ pub async fn run_conversion_loop(
let idx_start = page.saturating_sub(prerender / 2);
let idx_end = idx_start.saturating_add(prerender).min(images.len());
// If there's none to render, then why bother.
let Some(idx_end) = NonZeroUsize::new(idx_end) else {
return Ok(None);
};
// then we go through all the indices available to us and find the first one that has an
// image available to steal
let Some((page_info, new_iter)) = (idx_start..page)
.interleave(page..idx_end)
.enumerate()
// .skip(*iteration)
.find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx)))
let Some((page_info, new_iter, page_num)) =
InterleavedAroundWithMax::new(page, idx_start, idx_end)
.enumerate()
.take(prerender)
// .skip(*iteration)
.find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx, p_idx)))
else {
return Ok(None);
};
@@ -81,17 +135,45 @@ pub async fn run_conversion_loop(
y: 0
};
// We don't actually want to Crop this image, but we've already
// verified (with the ImageSurface stuff) that the image is the correct
// size for the area given, so to save ratatui the work of having to
// resize it, we tell them to crop it to fit.
let txt_img = picker
.new_protocol(dyn_img, img_area, Resize::None)
.map_err(|e| {
RenderError::Converting(format!(
"Couldn't convert DynamicImage to ratatui image: {e}"
))
})?;
let txt_img = match picker.protocol_type() {
ProtocolType::Kitty => {
let rn = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() % 1_000_000;
let mut img = if shms_work {
kittage::image::Image::shm_from(dyn_img, &format!("tdf_{pid}_{rn}_{page_num}"))
.map_err(|e| {
RenderError::Converting(format!("Couldn't write to shm: {e}"))
})?
} else {
kittage::image::Image::from(dyn_img)
};
img.num_or_id = NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap());
ConvertedImage::Kitty {
img: MaybeTransferred::NotYet(img),
cell_w: page_info.img_data.cell_w,
cell_h: page_info.img_data.cell_h
}
}
_ => ConvertedImage::Generic(
picker
.new_protocol(dyn_img, img_area, Resize::None)
.map_err(|e| {
RenderError::Converting(format!(
"Couldn't convert DynamicImage to ratatui image: {e}"
))
})?
)
};
log::debug!(
"got converted page for num {} with results {:?}",
page_info.page_num,
page_info.result_rects
);
// update the iteration to the iteration that we stole this image from
*iteration = new_iter;
@@ -130,7 +212,15 @@ pub async fn run_conversion_loop(
Err(TryRecvError::Disconnected) => return Ok(())
}
match next_page(&mut images, &mut picker, page, &mut iteration, prerender) {
match next_page(
&mut images,
&mut picker,
page,
&mut iteration,
prerender,
pid,
shms_work
) {
Ok(None) => break,
Ok(Some(img)) => sender.send(Ok(img))?,
Err(e) => sender.send(Err(e))?
+209
View File
@@ -0,0 +1,209 @@
use std::{io::Write, num::NonZeroU32};
use crossterm::{
cursor::MoveTo,
event::EventStream,
execute,
terminal::{disable_raw_mode, enable_raw_mode}
};
use image::DynamicImage;
use kittage::{
AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat,
action::Action,
delete::{ClearOrDelete, DeleteConfig, WhichToDelete},
display::{CursorMovementPolicy, DisplayConfig, DisplayLocation},
error::TransmitError,
image::Image,
medium::Medium
};
use ratatui::layout::Position;
use crate::converter::MaybeTransferred;
pub struct KittyReadyToDisplay<'tui> {
pub img: &'tui mut MaybeTransferred,
pub page_num: usize,
pub pos: Position,
pub display_loc: DisplayLocation
}
pub enum KittyDisplay<'tui> {
NoChange,
ClearImages,
DisplayImages(Vec<KittyReadyToDisplay<'tui>>)
}
pub struct DbgWriter<W: Write> {
w: W,
#[cfg(debug_assertions)]
buf: String
}
impl<W: Write> Write for DbgWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
#[cfg(debug_assertions)]
{
if let Ok(s) = std::str::from_utf8(buf) {
self.buf.push_str(s);
}
}
self.w.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
#[cfg(debug_assertions)]
{
log::debug!("Writing to kitty: {:?}", self.buf);
self.buf.clear();
}
self.w.flush()
}
}
pub async fn run_action<'image, 'data, 'es>(
action: Action<'image, 'data>,
ev_stream: &'es mut EventStream
) -> Result<ImageId, TransmitError<<&'es mut EventStream as AsyncInputReader>::Error>> {
let writer = DbgWriter {
w: std::io::stdout().lock(),
#[cfg(debug_assertions)]
buf: String::new()
};
action
.execute_async(writer, ev_stream)
.await
.map(|(_, i)| i)
}
pub async fn do_shms_work(ev_stream: &mut EventStream) -> bool {
let img = DynamicImage::new_rgb8(1, 1);
let pid = std::process::id();
let Ok(mut k_img) = kittage::image::Image::shm_from(img, &format!("tdf_test_{pid}")) else {
return false;
};
// apparently the terminal won't respond to queries unless they have an Id instead of a number
k_img.num_or_id = NumberOrId::Id(NonZeroU32::new(u32::MAX).unwrap());
enable_raw_mode().unwrap();
let res = run_action(Action::Query(&k_img), ev_stream).await;
disable_raw_mode().unwrap();
res.is_ok()
}
pub async fn display_kitty_images<'es>(
display: KittyDisplay<'_>,
ev_stream: &'es mut EventStream
) -> Result<
(),
(
Vec<usize>,
&'static str,
TransmitError<<&'es mut EventStream as AsyncInputReader>::Error>
)
> {
let images = match display {
KittyDisplay::NoChange => return Ok(()),
KittyDisplay::DisplayImages(_) | KittyDisplay::ClearImages => {
run_action(
Action::Delete(DeleteConfig {
effect: ClearOrDelete::Clear,
which: WhichToDelete::All
}),
ev_stream
)
.await
.map_err(|e| (vec![], "Couldn't clear previous images", e))?;
let KittyDisplay::DisplayImages(images) = display else {
return Ok(());
};
images
}
};
let mut err = None;
for KittyReadyToDisplay {
img,
page_num,
pos,
display_loc
} in images
{
let config = DisplayConfig {
location: display_loc,
cursor_movement: CursorMovementPolicy::DontMove,
..DisplayConfig::default()
};
execute!(std::io::stdout(), MoveTo(pos.x, pos.y)).unwrap();
log::debug!("going to display img {img:#?}");
log::debug!("displaying with config {config:#?}");
let this_err = match img {
MaybeTransferred::NotYet(image) => {
let mut fake_image = Image {
num_or_id: image.num_or_id,
format: PixelFormat::Rgb24(
ImageDimensions {
width: 0,
height: 0
},
None
),
medium: Medium::Direct {
chunk_size: None,
data: (&[]).into()
}
};
std::mem::swap(image, &mut fake_image);
let res = run_action(
Action::TransmitAndDisplay {
image: fake_image,
config,
placement_id: None
},
ev_stream
)
.await;
match res {
Ok(img_id) => {
*img = MaybeTransferred::Transferred(img_id);
Ok(())
}
Err(e) => Err((page_num, e))
}
}
MaybeTransferred::Transferred(image_id) => run_action(
Action::Display {
image_id: *image_id,
placement_id: *image_id,
config
},
ev_stream
)
.await
.map(|_| ())
.map_err(|e| (page_num, e))
};
log::debug!("this_err is {this_err:#?}");
if let Err((id, e)) = this_err {
let e = err.get_or_insert_with(|| (vec![], e));
e.0.push(id);
}
}
match err {
Some((replace, e)) => Err((replace, "Couldn't transfer image to the terminal", e)),
None => Ok(())
}
}
+45
View File
@@ -3,12 +3,57 @@ use std::num::NonZeroUsize;
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(PartialEq)]
pub enum PrerenderLimit {
All,
Limited(NonZeroUsize)
}
pub mod converter;
pub mod kitty;
pub mod renderer;
pub mod skip;
pub mod tui;
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum FitOrFill {
Fit,
Fill
}
pub struct ScaledResult {
width: f32,
height: f32,
scale_factor: f32
}
pub fn scale_img_for_area(
(img_width, img_height): (f32, f32),
(area_width, area_height): (f32, f32),
fit_or_fill: FitOrFill
) -> ScaledResult {
// and get its aspect ratio
let img_aspect_ratio = img_width / img_height;
// Then we get the full pixel dimensions of the area provided to us, and the aspect ratio
// of that area
let area_aspect_ratio = area_width / area_height;
// and get the ratio that this page would have to be scaled by to fit perfectly within the
// area provided to us.
// we do this first by comparing the aspec ratio of the page with the aspect ratio of the
// area to fit it within. If the aspect ratio of the page is larger, then we need to scale
// the width of the page to fill perfectly within the height of the area. Otherwise, we
// scale the height to fit perfectly. The dimension that _is not_ scaled to fit perfectly
// is scaled by the same factor as the dimension that _is_ scaled perfectly.
let scale_factor = match (img_aspect_ratio > area_aspect_ratio, fit_or_fill) {
(true, FitOrFill::Fit) | (false, FitOrFill::Fill) => area_width / img_width,
(false, FitOrFill::Fit) | (true, FitOrFill::Fill) => area_height / img_height
};
ScaledResult {
width: img_width * scale_factor,
height: img_height * scale_factor,
scale_factor
}
}
+279 -130
View File
@@ -1,9 +1,11 @@
use core::error::Error;
use core::{
error::Error,
num::{NonZeroU32, NonZeroUsize}
};
use std::{
borrow::Cow,
ffi::OsString,
io::{BufReader, Read, Stdout, Write, stdout},
num::NonZeroUsize,
path::PathBuf
};
@@ -15,14 +17,24 @@ use crossterm::{
enable_raw_mode, window_size
}
};
use flexi_logger::FileSpec;
use flume::{Sender, r#async::RecvStream};
use futures_util::{FutureExt, stream::StreamExt};
use kittage::{
action::Action,
delete::{ClearOrDelete, DeleteConfig, WhichToDelete},
error::{TerminalError, TransmitError}
};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui_image::picker::Picker;
use ratatui_image::{
FontSize,
picker::{Picker, ProtocolType}
};
use tdf::{
PrerenderLimit,
converter::{ConvertedPage, ConverterMsg, run_conversion_loop},
kitty::{KittyDisplay, display_kitty_images, do_shms_work, run_action},
renderer::{self, RenderError, RenderInfo, RenderNotif},
tui::{BottomMessage, InputAction, MessageSetting, Tui}
};
@@ -44,8 +56,27 @@ impl std::fmt::Debug for WrappedErr {
impl std::error::Error for WrappedErr {}
fn reset_term() {
_ = execute!(
std::io::stdout(),
LeaveAlternateScreen,
crossterm::cursor::Show,
crossterm::event::DisableMouseCapture
)
}
#[tokio::main]
async fn main() -> Result<(), WrappedErr> {
inner_main().await.inspect_err(|_| reset_term())
}
async fn inner_main() -> Result<(), WrappedErr> {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
reset_term();
hook(info);
}));
#[cfg(feature = "tracing")]
console_subscriber::init();
@@ -64,12 +95,24 @@ async fn main() -> Result<(), WrappedErr> {
optional -w,--white-color white: String
/// Custom black color, specified in css format (e.g "000000" or "rgb(0, 0, 0)")
optional -b,--black-color black: String
/// Print the version and exit
optional --version
/// PDF file to read
required file: PathBuf
optional file: PathBuf
};
let path = flags
.file
if flags.version {
println!("{}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let Some(file) = flags.file else {
return Err(WrappedErr(
"Please specify the file to open, e.g. `tdf ./my_example_pdf.pdf`".into()
));
};
let path = file
.canonicalize()
.map_err(|e| WrappedErr(format!("Cannot canonicalize provided file: {e}").into()))?;
@@ -89,8 +132,24 @@ async fn main() -> Result<(), WrappedErr> {
)
})?;
// need to keep it around throughout the lifetime of the program, but don't rly need to use it.
// Just need to make sure it doesn't get dropped yet.
let mut maybe_logger = None;
if std::env::var("RUST_LOG").is_ok() {
maybe_logger = Some(
flexi_logger::Logger::try_with_env()
.map_err(|e| WrappedErr(format!("Couldn't create initial logger: {e}").into()))?
.log_to_file(FileSpec::try_from("./debug.log").map_err(|e| {
WrappedErr(format!("Couldn't create FileSpec for logger: {e}").into())
})?)
.start()
.map_err(|e| WrappedErr(format!("Can't start logger: {e}").into()))?
);
}
let (watch_to_render_tx, render_rx) = flume::unbounded();
let tui_tx = watch_to_render_tx.clone();
let to_renderer = watch_to_render_tx.clone();
let (render_tx, tui_rx) = flume::unbounded();
let watch_to_tui_tx = render_tx.clone();
@@ -128,121 +187,20 @@ async fn main() -> Result<(), WrappedErr> {
})?;
if window_size.width == 0 || window_size.height == 0 {
// send the command code to get the terminal window size
print!("\x1b[14t");
std::io::stdout().flush().unwrap();
let (w, h) = get_font_size_through_stdio()?;
// we need to enable raw mode here since this bit of output won't print a newline; it'll
// just print the info it wants to tell us. So we want to get all characters as they come
enable_raw_mode().map_err(|e| {
WrappedErr(
format!("Can't enable raw mode, which is necessary to receive input: {e}").into()
)
})?;
// read in the returned size until we hit a 't' (which indicates to us it's done)
let input_vec = BufReader::new(std::io::stdin())
.bytes()
.filter_map(Result::ok)
.take_while(|b| *b != b't')
.collect::<Vec<_>>();
// and then disable raw mode again in case we return an error in this next section
disable_raw_mode().map_err(|e| {
WrappedErr(format!("Can't put the terminal back into a normal input state: {e}").into())
})?;
let input_line = String::from_utf8(input_vec).map_err(|e| {
WrappedErr(
format!(
"The terminal responded to our request for its font size by providing non-utf8 data: {e}"
)
.into()
)
})?;
let input_line = input_line
.trim_start_matches("\x1b[4")
.trim_start_matches(';');
// it should input it to us as `\e[4;<height>;<width>t`, so we need to split to get the h/w
// ignore the first val
let mut splits = input_line.split([';', 't']);
let (Some(h), Some(w)) = (splits.next(), splits.next()) else {
return Err(WrappedErr(
format!("Terminal responded with unparseable size response '{input_line}'").into()
));
};
window_size.height = h.parse::<u16>().map_err(|_| {
WrappedErr(
format!(
"Your terminal said its height is {h}, but that is not a 16-bit unsigned integer"
)
.into()
)
})?;
window_size.width = w.parse::<u16>().map_err(|_| {
WrappedErr(
format!(
"Your terminal said its width is {w}, but that is not a 16-bit unsigned integer"
)
.into()
)
})?;
window_size.width = w;
window_size.height = h;
}
// We need to create `picker` on this thread because if we create it on the `renderer` thread,
// it messes up something with user input. Input never makes it to the crossterm thing
let picker = Picker::from_query_stdio()
.map_err(|e| WrappedErr(match e {
ratatui_image::errors::Errors::NoFontSize =>
"Unable to detect your terminal's font size; this is an issue with your terminal emulator.\nPlease use a different terminal emulator or report this bug to tdf.".into(),
e => format!("Couldn't get the necessary information to set up images: {e}").into()
}))?;
// then we want to spawn off the rendering task
// We need to use the thread::spawn API so that this exists in a thread not owned by tokio,
// since the methods we call in `start_rendering` will panic if called in an async context
let prerender = flags
.prerender
.and_then(NonZeroUsize::new)
.map_or(PrerenderLimit::All, PrerenderLimit::Limited);
std::thread::spawn(move || {
renderer::start_rendering(
&file_path,
render_tx,
render_rx,
window_size,
prerender,
black,
white
)
});
let ev_stream = crossterm::event::EventStream::new();
let (to_converter, from_main) = flume::unbounded();
let (to_main, from_converter) = flume::unbounded();
tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20));
let file_name = path.file_name().map_or_else(
|| "Unknown file".into(),
|n| n.to_string_lossy().to_string()
);
let tui = Tui::new(file_name, flags.max_wide, flags.r_to_l.unwrap_or_default());
let backend = CrosstermBackend::new(std::io::stdout());
let mut term = Terminal::new(backend).map_err(|e| {
WrappedErr(format!("Couldn't set up crossterm's terminal backend: {e}").into())
})?;
term.skip_diff(true);
let cell_height_px = window_size.height / window_size.rows;
let cell_width_px = window_size.width / window_size.columns;
execute!(
term.backend_mut(),
std::io::stdout(),
EnterAlternateScreen,
crossterm::cursor::Hide
crossterm::cursor::Hide,
crossterm::event::EnableMouseCapture
)
.map_err(|e| {
WrappedErr(
@@ -252,15 +210,99 @@ async fn main() -> Result<(), WrappedErr> {
.into()
)
})?;
// We need to create `picker` on this thread because if we create it on the `renderer` thread,
// it messes up something with user input. Input never makes it to the crossterm thing
let picker = Picker::from_query_stdio()
.or_else(|e| match e {
ratatui_image::errors::Errors::NoFontSize if
window_size.width != 0
&& window_size.height != 0
&& window_size.columns != 0
&& window_size.rows != 0
=> Ok(Picker::from_fontsize((cell_width_px, cell_height_px))),
ratatui_image::errors::Errors::NoFontSize => Err(WrappedErr(
"Unable to detect your terminal's font size; this is an issue with your terminal emulator.\nPlease use a different terminal emulator or report this bug to tdf.".into()
)),
e => Err(WrappedErr(format!("Couldn't get the necessary information to set up images: {e}").into()))
})?;
// then we want to spawn off the rendering task
// We need to use the thread::spawn API so that this exists in a thread not owned by tokio,
// since the methods we call in `start_rendering` will panic if called in an async context
let prerender = flags
.prerender
.and_then(NonZeroUsize::new)
.map_or(PrerenderLimit::All, PrerenderLimit::Limited);
std::thread::spawn(move || {
renderer::start_rendering(
&file_path,
render_tx,
render_rx,
cell_height_px,
cell_width_px,
prerender,
black,
white
)
});
let font_size = picker.font_size();
let mut ev_stream = crossterm::event::EventStream::new();
let (to_converter, from_main) = flume::unbounded();
let (to_main, from_converter) = flume::unbounded();
let is_kitty = picker.protocol_type() == ProtocolType::Kitty;
let shms_work = is_kitty && do_shms_work(&mut ev_stream).await;
tokio::spawn(run_conversion_loop(
to_main, from_main, picker, 20, shms_work
));
let file_name = path.file_name().map_or_else(
|| "Unknown file".into(),
|n| n.to_string_lossy().to_string()
);
let tui = Tui::new(
file_name,
flags.max_wide,
flags.r_to_l.unwrap_or_default(),
is_kitty
);
let backend = CrosstermBackend::new(std::io::stdout());
let mut term = Terminal::new(backend).map_err(|e| {
WrappedErr(format!("Couldn't set up crossterm's terminal backend: {e}").into())
})?;
term.skip_diff(true);
enable_raw_mode().map_err(|e| {
WrappedErr(
format!("Can't enable raw mode, which is necessary to receive input: {e}").into()
)
})?;
if is_kitty {
run_action(
Action::Delete(DeleteConfig {
effect: ClearOrDelete::Delete,
which: WhichToDelete::IdRange(NonZeroU32::new(1).unwrap()..=NonZeroU32::MAX)
}),
&mut ev_stream
)
.await
.map_err(|e| {
WrappedErr(format!("Couldn't delete all previous images from memory: {e}").into())
})?;
}
let fullscreen = flags.fullscreen.unwrap_or_default();
let main_area = Tui::main_layout(&term.get_frame(), fullscreen);
tui_tx
to_renderer
.send(RenderNotif::Area(main_area.page_area))
.map_err(|e| {
WrappedErr(
@@ -273,14 +315,15 @@ async fn main() -> Result<(), WrappedErr> {
enter_redraw_loop(
ev_stream,
tui_tx,
to_renderer,
tui_rx,
to_converter,
from_converter,
fullscreen,
tui,
&mut term,
main_area
main_area,
font_size
)
.await
.map_err(|e| {
@@ -295,11 +338,14 @@ async fn main() -> Result<(), WrappedErr> {
execute!(
term.backend_mut(),
LeaveAlternateScreen,
crossterm::cursor::Show
crossterm::cursor::Show,
crossterm::event::DisableMouseCapture
)
.unwrap();
disable_raw_mode().unwrap();
drop(maybe_logger);
Ok(())
}
@@ -307,20 +353,22 @@ async fn main() -> Result<(), WrappedErr> {
#[expect(clippy::too_many_arguments)]
async fn enter_redraw_loop(
mut ev_stream: EventStream,
tui_tx: Sender<RenderNotif>,
to_renderer: Sender<RenderNotif>,
mut tui_rx: RecvStream<'_, Result<RenderInfo, RenderError>>,
to_converter: Sender<ConverterMsg>,
mut from_converter: RecvStream<'_, Result<ConvertedPage, RenderError>>,
mut fullscreen: bool,
mut tui: Tui,
term: &mut Terminal<CrosstermBackend<Stdout>>,
mut main_area: tdf::tui::RenderLayout
mut main_area: tdf::tui::RenderLayout,
font_size: FontSize
) -> Result<(), Box<dyn Error>> {
loop {
let mut needs_redraw = true;
let next_ev = ev_stream.next().fuse();
tokio::select! {
// First we check if we have any keystrokes
Some(ev) = ev_stream.next().fuse() => {
Some(ev) = next_ev => {
// If we can't get user input, just crash.
let ev = ev.expect("Couldn't get any user input");
@@ -330,12 +378,15 @@ async fn enter_redraw_loop(
InputAction::Redraw => (),
InputAction::QuitApp => return Ok(()),
InputAction::JumpingToPage(page) => {
tui_tx.send(RenderNotif::JumpToPage(page))?;
to_renderer.send(RenderNotif::JumpToPage(page))?;
to_converter.send(ConverterMsg::GoToPage(page))?;
},
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
InputAction::Invert => tui_tx.send(RenderNotif::Invert)?,
InputAction::Search(term) => to_renderer.send(RenderNotif::Search(term))?,
InputAction::Invert => to_renderer.send(RenderNotif::Invert)?,
InputAction::Fullscreen => fullscreen = !fullscreen,
InputAction::SwitchRenderZoom(f_or_f) => {
to_renderer.send(RenderNotif::SwitchFitOrFill(f_or_f)).unwrap();
}
}
}
},
@@ -359,7 +410,12 @@ async fn enter_redraw_loop(
}
Some(img_res) = from_converter.next() => {
match img_res {
Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results),
Ok(ConvertedPage { page, num, num_results }) => {
tui.page_ready(page, num, num_results);
if num == tui.page {
needs_redraw = true;
}
},
Err(e) => tui.show_error(e),
}
},
@@ -368,15 +424,41 @@ async fn enter_redraw_loop(
let new_area = Tui::main_layout(&term.get_frame(), fullscreen);
if new_area != main_area {
main_area = new_area;
tui_tx.send(RenderNotif::Area(main_area.page_area))?;
to_renderer.send(RenderNotif::Area(main_area.page_area))?;
needs_redraw = true;
}
if needs_redraw {
let mut to_display = KittyDisplay::NoChange;
term.draw(|f| {
tui.render(f, &main_area);
to_display = tui.render(f, &main_area, font_size);
})?;
execute!(stdout(), EndSynchronizedUpdate)?;
let maybe_err = display_kitty_images(to_display, &mut ev_stream).await;
if let Err((to_replace, err_desc, enum_err)) = maybe_err {
match enum_err {
// This is the error that kitty & ghostty provide us when they delete an
// image due to memory constraints, so if we get it, we just fix it by
// re-rendering so it don't display it to the user
//
// [TODO] maybe when we detect that an image was deleted, we probe the
// terminal for the pages around it to see if they were deleted too and if
// they were, we re-render them? idk
TransmitError::Terminal(TerminalError::NoEntity(_)) => (),
_ => tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!(
"{err_desc}: {enum_err}"
))))
}
for page_num in to_replace {
tui.page_failed_display(page_num);
// So that they get re-rendered and sent over again
to_renderer.send(RenderNotif::PageNeedsReRender(page_num))?;
}
}
execute!(stdout().lock(), EndSynchronizedUpdate)?;
}
}
}
@@ -424,3 +506,70 @@ fn parse_color_to_i32(cs: &str) -> Result<i32, csscolorparser::ParseColorError>
let [r, g, b, _] = color.to_rgba8();
Ok(i32::from_be_bytes([0, r, g, b]))
}
fn get_font_size_through_stdio() -> Result<(u16, u16), WrappedErr> {
// send the command code to get the terminal window size
print!("\x1b[14t");
std::io::stdout().flush().unwrap();
// we need to enable raw mode here since this bit of output won't print a newline; it'll
// just print the info it wants to tell us. So we want to get all characters as they come
enable_raw_mode().map_err(|e| {
WrappedErr(
format!("Can't enable raw mode, which is necessary to receive input: {e}").into()
)
})?;
// read in the returned size until we hit a 't' (which indicates to us it's done)
let input_vec = BufReader::new(std::io::stdin())
.bytes()
.filter_map(Result::ok)
.take_while(|b| *b != b't')
.collect::<Vec<_>>();
// and then disable raw mode again in case we return an error in this next section
disable_raw_mode().map_err(|e| {
WrappedErr(format!("Can't put the terminal back into a normal input state: {e}").into())
})?;
let input_line = String::from_utf8(input_vec).map_err(|e| {
WrappedErr(
format!(
"The terminal responded to our request for its font size by providing non-utf8 data: {e}"
)
.into()
)
})?;
let input_line = input_line
.trim_start_matches("\x1b[4")
.trim_start_matches(';');
// it should input it to us as `\e[4;<height>;<width>t`, so we need to split to get the h/w
// ignore the first val
let mut splits = input_line.split([';', 't']);
let (Some(h), Some(w)) = (splits.next(), splits.next()) else {
return Err(WrappedErr(
format!("Terminal responded with unparseable size response '{input_line}'").into()
));
};
let h = h.parse::<u16>().map_err(|_| {
WrappedErr(
format!(
"Your terminal said its height is {h}, but that is not a 16-bit unsigned integer"
)
.into()
)
})?;
let w = w.parse::<u16>().map_err(|_| {
WrappedErr(
format!(
"Your terminal said its width is {w}, but that is not a 16-bit unsigned integer"
)
.into()
)
})?;
Ok((w, h))
}
+111 -86
View File
@@ -1,19 +1,24 @@
use std::{thread::sleep, time::Duration};
use std::{collections::VecDeque, num::NonZeroUsize, thread::sleep, time::Duration};
use crossterm::terminal::WindowSize;
use flume::{Receiver, SendError, Sender, TryRecvError};
use itertools::Itertools;
use mupdf::{
Colorspace, Document, Matrix, Page, Pixmap, Quad, TextPageOptions, text_page::SearchHitResponse
Colorspace, Document, Matrix, Page, Pixmap, Quad, TextPageFlags, text_page::SearchHitResponse
};
use ratatui::layout::Rect;
use crate::PrerenderLimit;
use crate::{
FitOrFill, PrerenderLimit, ScaledResult, scale_img_for_area, skip::InterleavedAroundWithMax
};
const KITTY_MAX_W_OR_H: f32 = 10_000.0;
#[derive(Debug)]
pub enum RenderNotif {
Area(Rect),
JumpToPage(usize),
PageNeedsReRender(usize),
Search(String),
SwitchFitOrFill(FitOrFill),
Reload,
Invert
}
@@ -73,12 +78,13 @@ pub fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
// We're allowing passing by value here because this is only called once, at the beginning of the
// program, and the arguments that 'should' be passed by value (`receiver` and `size`) would
// probably be more performant if accessed by-value instead of through a reference. Probably.
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
pub fn start_rendering(
path: &str,
sender: Sender<Result<RenderInfo, RenderError>>,
receiver: Receiver<RenderNotif>,
size: WindowSize,
col_h: u16,
col_w: u16,
prerender: PrerenderLimit,
black: i32,
white: i32
@@ -88,13 +94,14 @@ pub fn start_rendering(
let mut search_term = None;
// And although the font size could theoretically change, we aren't accounting for that right
// now, so we just keep this out of the loop.
let col_w = size.width / size.columns;
let col_h = size.height / size.rows;
// now, so we just use the values passed in.
let mut stored_doc = None;
let mut invert = false;
let mut preserved_area = None;
let mut fit_or_fill = FitOrFill::Fit;
let mut need_rerender = VecDeque::new();
'reload: loop {
let doc = match Document::open(path) {
@@ -128,7 +135,13 @@ pub fn start_rendering(
};
let n_pages = match doc.page_count() {
Ok(n) => n as usize,
Ok(n) => match NonZeroUsize::new(n as usize) {
Some(n) => n,
None => {
sleep(Duration::from_secs(1));
continue 'reload;
}
},
Err(e) => {
sender.send(Err(RenderError::Doc(e)))?;
// just basic backoff i think
@@ -137,7 +150,7 @@ pub fn start_rendering(
}
};
sender.send(Ok(RenderInfo::NumPages(n_pages)))?;
sender.send(Ok(RenderInfo::NumPages(n_pages.get())))?;
// We're using this vec of bools to indicate which page numbers have already been rendered,
// to support people jumping to specific pages and having quick rendering results. We
@@ -145,7 +158,7 @@ pub fn start_rendering(
// doing basically nothing, but if we get a notification that something has been jumped to,
// then we can split at that page and render at both sides of it
let mut rendered = Vec::new();
fill_default::<PrevRender>(&mut rendered, n_pages);
fill_default::<PrevRender>(&mut rendered, n_pages.get());
let mut start_point = 0;
// This is kinda a weird way of doing this, but if we get a notification that the area
@@ -165,6 +178,9 @@ pub fn start_rendering(
new_area
});
let area_w = f32::from(area.width) * f32::from(col_w);
let area_h = f32::from(area.height) * f32::from(col_h);
// what we do with a notif is the same regardless of if we're in the middle of
// rendering the list of pages or we're all done
macro_rules! handle_notif {
@@ -180,13 +196,24 @@ pub fn start_rendering(
}
RenderNotif::Area(new_area) => {
preserved_area = Some(new_area);
fill_default(&mut rendered, n_pages);
fill_default(&mut rendered, n_pages.get());
continue 'render_pages;
}
RenderNotif::SwitchFitOrFill(f_or_f) =>
if f_or_f != fit_or_fill {
fit_or_fill = f_or_f;
fill_default(&mut rendered, n_pages.get());
continue 'render_pages;
},
RenderNotif::JumpToPage(page) => {
start_point = page;
continue 'render_pages;
}
RenderNotif::PageNeedsReRender(page) => {
rendered[page].successful = false;
need_rerender.push_back(page);
continue 'render_pages;
}
RenderNotif::Search(term) => {
if term.is_empty() {
// If the term is set to nothing, then we don't need to re-render
@@ -216,28 +243,21 @@ pub fn start_rendering(
}
let any_not_searched = rendered.iter().any(|r| r.num_search_found.is_none());
let (left, right) = rendered.split_at_mut(start_point);
// This is our iterator over all the pages we want to look at and render. It uses this
// weird 'interleave' thing to render pages on *both sides* of the currently-displayed
// page in case they device to go forward or backwards.
let page_iter = right
.iter_mut()
.enumerate()
.map(move |(idx, p)| (idx + start_point, p))
.interleave(
left.iter_mut()
.rev()
.enumerate()
.map(move |(idx, p)| (start_point - (idx + 1), p))
)
.take(match (&prerender, &search_term) {
let page_iter = PopOnNext {
inner: &mut need_rerender
}
.chain(InterleavedAroundWithMax::new(start_point, 0, n_pages).take(
match (&prerender, &search_term) {
// If the user has limited the amount of pages they want to prerender, then we
// just do what they ask. Nice and easy.
(PrerenderLimit::Limited(l), _) => l.get(),
// If they haven't limited it, but we don't have any search term that we're
// currently looking for, just go for all of it
(PrerenderLimit::All, None) => n_pages,
(PrerenderLimit::All, None) => n_pages.get(),
// If they haven't limited it, and we DO have a search term we need to look
// for, just do 20 so that we don't dramatically slow down the search process
// since they've specifically initiated that and so we want it to take priority
@@ -245,15 +265,15 @@ pub fn start_rendering(
if any_not_searched {
20
} else {
n_pages
n_pages.get()
},
});
let area_w = f32::from(area.width) * f32::from(col_w);
let area_h = f32::from(area.height) * f32::from(col_h);
}
));
// we go through each page
for (num, rendered) in page_iter {
for page_num in page_iter {
let rendered = &mut rendered[page_num];
// we only want to continue if one of the following is met:
// 1. It failed to render last time (we want to retry)
// 2. The `contained_term` is set to Unknown, meaning that we need to at least
@@ -262,18 +282,9 @@ pub fn start_rendering(
continue;
}
// check if we've been told to change the area that we're rendering to,
// or if we're told to rerender
match receiver.try_recv() {
// If it's disconnected, then the main loop is done, so we should just give up
Err(TryRecvError::Disconnected) => return Ok(()),
Ok(notif) => handle_notif!(notif),
Err(TryRecvError::Empty) => ()
};
// We know this is in range 'cause we're iterating over it but we still just want
// to be safe
let page = match doc.load_page(num as i32) {
let page = match doc.load_page(page_num as i32) {
Err(e) => {
sender.send(Err(RenderError::Doc(e)))?;
continue;
@@ -289,6 +300,7 @@ pub fn start_rendering(
invert,
black,
white,
fit_or_fill,
(area_w, area_h)
) {
// If that fn returned Some, that means it needed to be re-rendered for some
@@ -303,6 +315,8 @@ pub fn start_rendering(
continue;
};
log::debug!("got pixmap for page {page_num} with WxH {w}x{h}");
rendered.num_search_found = Some(ctx.result_rects.len());
rendered.successful = true;
@@ -312,13 +326,22 @@ pub fn start_rendering(
cell_w: (ctx.surface_w / f32::from(col_w)) as u16,
cell_h: (ctx.surface_h / f32::from(col_h)) as u16
},
page_num: num,
page_num,
result_rects: ctx.result_rects
})))?;
}
// And if we got an error, then obviously we need to propagate that
Err(e) => sender.send(Err(RenderError::Doc(e)))?
}
// check if we've been told to change the area that we're rendering to,
// or if we're told to rerender
match receiver.try_recv() {
// If it's disconnected, then the main loop is done, so we should just give up
Err(TryRecvError::Disconnected) => return Ok(()),
Ok(notif) => handle_notif!(notif),
Err(TryRecvError::Empty) => ()
};
}
// Now, if we have a search term, we want to look through the rest of the document past
@@ -373,7 +396,7 @@ pub fn start_rendering(
// now, we want to check if we've gone past the end - if so, we go back to the
// beginning so we can get the pages before the current one.
if search_start > n_pages {
if search_start > n_pages.get() {
if start_point == 0 {
break;
}
@@ -399,7 +422,7 @@ pub fn start_rendering(
// So now we've just *searched* all the pages but not necessarily rendered all of them.
// So if there are any we have yet to render, we need to loop back to the beginning of
// this loop to continue rendering all of them
if rendered.iter().any(|r| !r.successful) {
if rendered.iter().any(|r| !r.successful) && prerender == PrerenderLimit::All {
continue;
}
@@ -423,6 +446,7 @@ struct RenderedContext {
result_rects: Vec<HighlightRect>
}
#[expect(clippy::too_many_arguments)]
fn render_single_page_to_ctx(
page: &Page,
search_term: Option<&str>,
@@ -430,6 +454,7 @@ fn render_single_page_to_ctx(
invert: bool,
black: i32,
white: i32,
fit_or_fill: FitOrFill,
(area_w, area_h): (f32, f32)
) -> Result<RenderedContext, mupdf::error::Error> {
let result_rects = match prev_render.num_search_found {
@@ -440,30 +465,21 @@ fn render_single_page_to_ctx(
// then, get the size of the page
let bounds = page.bounds()?;
let (p_width, p_height) = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0);
let page_dim = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0);
// and get its aspect ratio
let p_aspect_ratio = p_width / p_height;
let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill);
let ScaledResult {
width: mut surface_w,
height: mut surface_h,
mut scale_factor
} = scaled;
// Then we get the full pixel dimensions of the area provided to us, and the aspect ratio
// of that area
let area_aspect_ratio = area_w / area_h;
// and get the ratio that this page would have to be scaled by to fit perfectly within the
// area provided to us.
// we do this first by comparing the aspec ratio of the page with the aspect ratio of the
// area to fit it within. If the aspect ratio of the page is larger, then we need to scale
// the width of the page to fill perfectly within the height of the area. Otherwise, we
// scale the height to fit perfectly. The dimension that _is not_ scaled to fit perfectly
// is scaled by the same factor as the dimension that _is_ scaled perfectly.
let scale_factor = if p_aspect_ratio > area_aspect_ratio {
area_w / p_width
} else {
area_h / p_height
};
let surface_w = p_width * scale_factor;
let surface_h = p_height * scale_factor;
if surface_w > KITTY_MAX_W_OR_H || surface_h > KITTY_MAX_W_OR_H {
let descale = (surface_w / KITTY_MAX_W_OR_H).max(surface_h / KITTY_MAX_W_OR_H);
surface_w /= descale;
surface_h /= descale;
scale_factor /= descale;
}
let colorspace = Colorspace::device_rgb();
let matrix = Matrix::new_scale(scale_factor, scale_factor);
@@ -504,7 +520,7 @@ fn render_single_page_to_ctx(
})
}
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct HighlightRect {
pub ul_x: u32,
pub ul_y: u32,
@@ -520,15 +536,14 @@ fn search_page(
) -> Result<Vec<Quad>, mupdf::error::Error> {
search_term
.map(|term| {
page.to_text_page(TextPageOptions::empty())
.and_then(|page| {
let mut v = Vec::with_capacity(trusted_search_results);
page.search_cb(term, &mut v, |v, results| {
v.extend(results.iter().cloned());
SearchHitResponse::ContinueSearch
})
.map(|_| v)
page.to_text_page(TextPageFlags::empty()).and_then(|page| {
let mut v = Vec::with_capacity(trusted_search_results);
page.search_cb(term, &mut v, |v, results| {
v.extend(results.iter().cloned());
SearchHitResponse::ContinueSearch
})
.map(|_| v)
})
})
.transpose()
.map(Option::unwrap_or_default)
@@ -536,13 +551,23 @@ fn search_page(
#[inline]
fn count_search_results(page: &Page, search_term: &str) -> Result<usize, mupdf::error::Error> {
page.to_text_page(TextPageOptions::empty())
.and_then(|page| {
let mut count = 0;
page.search_cb(search_term, &mut count, |count, results| {
*count += results.len();
SearchHitResponse::ContinueSearch
})?;
Ok(count)
})
page.to_text_page(TextPageFlags::empty()).and_then(|page| {
let mut count = 0;
page.search_cb(search_term, &mut count, |count, results| {
*count += results.len();
SearchHitResponse::ContinueSearch
})?;
Ok(count)
})
}
struct PopOnNext<'a> {
inner: &'a mut VecDeque<usize>
}
impl<'a> Iterator for PopOnNext<'a> {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
self.inner.pop_front()
}
}
+106
View File
@@ -1,3 +1,5 @@
use std::num::NonZeroUsize;
use ratatui::widgets::Widget;
pub struct Skip {
@@ -19,3 +21,107 @@ impl Widget for Skip {
}
}
}
enum PlusOrMinus {
Plus,
Minus
}
pub struct InterleavedAroundWithMax {
// starts at this number
around: usize,
inclusive_min: usize,
// this iterator can only produce values in [0..max)
exclusive_max: NonZeroUsize,
// the next time we call `next()`, this value should be combined with `around` according to
// `next_op`, then, after next_op is inverted, incremented if next_op was negative before being
// inverted.
next_change: usize,
// How `next_change` should be applied to `around` next time `next()` is called
next_op: PlusOrMinus
}
impl InterleavedAroundWithMax {
/// the following must hold or else this is liable to panic or produce nonsense values:
/// - inclusive_min < exclusive_max
/// - inclusive_min <= around <= exclusive_max
pub fn new(around: usize, inclusive_min: usize, exclusive_max: NonZeroUsize) -> Self {
Self {
around,
inclusive_min,
exclusive_max,
next_change: 0,
next_op: PlusOrMinus::Minus
}
}
}
impl Iterator for InterleavedAroundWithMax {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
let actual_change = self.next_change % (self.exclusive_max.get() - self.inclusive_min);
let to_return = match self.next_op {
// If we're supposed to add them and we need it to wrap, then try to add them together
// 'cause we need special behavior if it overflows usize's limits
PlusOrMinus::Plus => match self.around.checked_add(actual_change) {
// If we added it and it's within the range, we're chillin
Some(next_val) if next_val < self.exclusive_max.get() => next_val,
// If we added it and it's not within the range, do next_val % (self.max + 1), e.g.
// if max is 20, we were at 15, and we added 7, we should get 1 (because +5 would
// hit the max, then 0, then 1). So adding 1 before the modulo makes it hit the
// right numbers. And we can be sure the + here doesn't overflow 'cause we already
// checked the `usize::MAX` up above
Some(next_val) => (next_val % self.exclusive_max.get()) + self.inclusive_min,
// If we added them and it would've overflowed usize::MAX, then we see how much
// of the change would be remaining after reaching `max`
None =>
(actual_change - (self.exclusive_max.get() - actual_change))
+ self.inclusive_min,
},
PlusOrMinus::Minus => match self.around.checked_sub(actual_change) {
// If we can just minus it, cool cool. All is good.
Some(next_val) if next_val >= self.inclusive_min => next_val,
// If we can minus it but it goes below our min, then see how much below it went
// and just manually wrap it around
Some(next_val) => self.exclusive_max.get() - (self.inclusive_min - next_val),
// If we can't...
None => {
// then we see how much of the change would be remaining after hitting the
// minimum
let remaining = actual_change - (self.around - self.inclusive_min);
// and then we take that away from the top!
self.exclusive_max.get() - remaining
}
}
};
self.next_op = match self.next_op {
PlusOrMinus::Plus => PlusOrMinus::Minus,
PlusOrMinus::Minus => {
self.next_change = (self.next_change + 1) % self.exclusive_max.get();
PlusOrMinus::Plus
}
};
Some(to_return)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iter_works() {
let got = InterleavedAroundWithMax::new(5, 2, NonZeroUsize::new(21).unwrap())
.take(30)
.collect::<Vec<_>>();
assert_eq!(got, vec![
5, 6, 4, 7, 3, 8, 2, 9, 20, 10, 19, 11, 18, 12, 17, 13, 16, 14, 15, 15, 14, 16, 13, 17,
12, 18, 11, 19, 10, 20
]);
}
}
+358 -141
View File
@@ -8,28 +8,32 @@ use crossterm::{
enable_raw_mode
}
};
use kittage::display::DisplayLocation;
use nix::{
sys::signal::{Signal::SIGSTOP, kill},
unistd::Pid
};
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
layout::{Constraint, Flex, Layout, Position, Rect},
style::{Color, Style},
symbols::border,
text::{Span, Text},
widgets::{Block, Borders, Clear, Padding}
text::Span,
widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}
};
use ratatui_image::{Image, protocol::Protocol};
use ratatui_image::{FontSize, Image};
use crate::{
FitOrFill,
converter::{ConvertedImage, MaybeTransferred},
kitty::{KittyDisplay, KittyReadyToDisplay},
renderer::{RenderError, fill_default},
skip::Skip
};
pub struct Tui {
name: String,
page: usize,
pub page: usize,
last_render: LastRender,
bottom_msg: BottomMessage,
// we use `prev_msg` to, for example, restore the 'search results' message on the bottom after
@@ -37,10 +41,12 @@ pub struct Tui {
prev_msg: Option<BottomMessage>,
rendered: Vec<RenderedInfo>,
page_constraints: PageConstraints,
showing_help_msg: bool
showing_help_msg: bool,
is_kitty: bool,
zoom: Option<Zoom>
}
#[derive(Default, Debug)]
#[derive(Default)]
struct LastRender {
// Used as a way to track if we need to draw the images, to save ratatui from doing a lot of
// diffing work
@@ -69,12 +75,25 @@ struct PageConstraints {
r_to_l: bool
}
#[derive(Default, Debug)]
struct Zoom {
// just how much 'zoom' you have. Doesn't relate to anything specific yet, except that 0 means
// it fills the screen (instead of fits)
level: i16,
// how many terminal-cells worth of content overflow the left side of the screen (and are thus
// not displayed)
cell_pan_from_left: u16,
// how many terminal-cells worth of content overflow the top side of the screen (and are thus
// not displayed)
cell_pan_from_top: u16
}
// This seems like a kinda weird struct because it holds two optionals but any representation
// within it is valid; I think it's the best way to represent it
#[derive(Default)]
struct RenderedInfo {
pub struct RenderedInfo {
// The image, if it has been rendered by `Converter` to that struct
img: Option<Protocol>,
img: Option<ConvertedImage>,
// The number of results for the current search term that have been found on this page. None if
// we haven't checked this page yet
// Also this isn't the most efficient representation of this value, but it's accurate, so like
@@ -89,7 +108,7 @@ pub struct RenderLayout {
}
impl Tui {
pub fn new(name: String, max_wide: Option<NonZeroUsize>, r_to_l: bool) -> Tui {
pub fn new(name: String, max_wide: Option<NonZeroUsize>, r_to_l: bool, is_kitty: bool) -> Tui {
Self {
name,
page: 0,
@@ -98,7 +117,9 @@ impl Tui {
last_render: LastRender::default(),
rendered: vec![],
page_constraints: PageConstraints { max_wide, r_to_l },
showing_help_msg: false
showing_help_msg: false,
is_kitty,
zoom: None
}
}
@@ -127,105 +148,27 @@ impl Tui {
}
// TODO: Make a way to fill the width of the screen with one page and scroll down to view it
pub fn render(&mut self, frame: &mut Frame<'_>, full_layout: &RenderLayout) {
#[must_use]
pub fn render<'s>(
&'s mut self,
frame: &mut Frame<'_>,
full_layout: &RenderLayout,
font_size: FontSize
) -> KittyDisplay<'s> {
if self.showing_help_msg {
self.render_help_msg(frame);
return;
return KittyDisplay::ClearImages;
}
if let Some((top_area, bottom_area)) = full_layout.top_and_bottom {
let top_block = Block::new()
.padding(Padding {
right: 2,
left: 2,
..Padding::default()
})
.borders(Borders::BOTTOM);
let top_area = top_block.inner(top_area);
let page_nums_text = format!("{} / {}", self.page + 1, self.rendered.len());
let top_layout = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(page_nums_text.len() as u16)
])
.split(top_area);
let title = Span::styled(&self.name, Style::new().fg(Color::Cyan));
let page_nums = Span::styled(&page_nums_text, Style::new().fg(Color::Cyan));
frame.render_widget(top_block, top_area);
frame.render_widget(title, top_layout[0]);
frame.render_widget(page_nums, top_layout[1]);
let bottom_block = Block::new()
.padding(Padding {
top: 1,
right: 2,
left: 2,
bottom: 0
})
.borders(Borders::TOP);
let bottom_inside_block = bottom_block.inner(bottom_area);
frame.render_widget(bottom_block, bottom_area);
let rendered_str = if !self.rendered.is_empty() {
format!(
"Rendered: {}%",
(self.rendered.iter().filter(|i| i.img.is_some()).count() * 100)
/ self.rendered.len()
)
} else {
String::new()
};
let bottom_layout = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(rendered_str.len() as u16)
])
.split(bottom_inside_block);
let rendered_span = Span::styled(&rendered_str, Style::new().fg(Color::Cyan));
frame.render_widget(rendered_span, bottom_layout[1]);
let (msg_str, color): (Cow<'_, str>, _) = match self.bottom_msg {
BottomMessage::Help => ("?: Show help page".into(), Color::Blue),
BottomMessage::Error(ref e) => (e.as_str().into(), Color::Red),
BottomMessage::Input(ref input_state) => (
match input_state {
InputCommand::GoToPage(page) => format!("Go to: {page}"),
InputCommand::Search(s) => format!("Search: {s}")
}
.into(),
Color::Blue
),
BottomMessage::SearchResults(ref term) => {
let num_found = self
.rendered
.iter()
.filter_map(|r| r.num_results)
.sum::<usize>();
let num_searched = self
.rendered
.iter()
.filter(|r| r.num_results.is_some())
.count() * 100;
(
format!(
"Results for '{term}': {num_found} (searched: {}%)",
num_searched / self.rendered.len()
)
.into(),
Color::Blue
)
}
BottomMessage::Reloaded => ("Document was reloaded!".into(), Color::Blue)
};
let span = Span::styled(msg_str, Style::new().fg(color));
frame.render_widget(span, bottom_layout[0]);
if let Some(t_and_b) = full_layout.top_and_bottom {
Self::render_top_and_bottom(
t_and_b,
self.page,
&self.rendered,
&self.name,
frame,
&self.bottom_msg
);
}
let mut img_area = full_layout.page_area;
@@ -237,7 +180,98 @@ impl Tui {
// be written and set to skip it so that ratatui doesn't spend a lot of time diffing it
// each re-render
frame.render_widget(Skip::new(true), img_area);
KittyDisplay::NoChange
} else {
if let Some(ref mut zoom) = self.zoom {
// yes this is ugly and I hate it. it's due to the limitations that currently exist
// in the borrow checker. Once `-Zpolonius=next` is stabilized, we can rework this
// to look like what we expect.
// See https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md#problem-case-3-conditional-control-flow-across-functions
// You can also rewrite this to just if an `if let` and run it under
// `RUSTFLAGS="-Zpolonius=next"` and see that it works
if self.rendered[self.page]
.img
.as_ref()
.is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. }))
{
let Some(ConvertedImage::Kitty {
ref mut img,
cell_w,
cell_h
}) = self.rendered[self.page].img
else {
unreachable!()
};
log::debug!("zoom is now {zoom:#?}");
log::debug!("img_area is {img_area:#?}");
if zoom.level < 0 {
img_area = Rect {
width: img_area
.width
.saturating_sub((zoom.level * 2).unsigned_abs())
.max(1),
x: img_area.x + (zoom.level.unsigned_abs().min(img_area.width / 2)),
..img_area
}
}
log::debug!("after adjustment, img_area is {img_area:#?}");
// Ugh I don't like this logic. I wish we could simplify it.
let img_width = f32::from(cell_w);
let img_height = f32::from(cell_h);
let img_area_width = f32::from(img_area.width);
let img_area_height = f32::from(img_area.height);
let available_to_real_width_ratio = img_area_width / img_width;
let available_to_real_height_ratio = img_area_height / img_height;
let (new_cell_width, new_cell_height) =
if available_to_real_width_ratio > available_to_real_height_ratio {
(img_width, img_area_height / available_to_real_width_ratio)
} else {
(img_area_width / available_to_real_height_ratio, img_height)
};
log::debug!("new_cell stuff is {new_cell_width}x{new_cell_height}");
let width = (new_cell_width * f32::from(font_size.0)) as u32;
let height = (new_cell_height * f32::from(font_size.1)) as u32;
self.last_render = LastRender {
rect: size,
pages_shown: 1,
unused_width: 0
};
zoom.cell_pan_from_left = zoom
.cell_pan_from_left
.min(cell_w.saturating_sub(new_cell_width as u16));
zoom.cell_pan_from_top = zoom
.cell_pan_from_top
.min(cell_h.saturating_sub(new_cell_height as u16));
return KittyDisplay::DisplayImages(vec![KittyReadyToDisplay {
img,
page_num: self.page,
pos: Position {
x: img_area.x,
y: img_area.y
},
display_loc: DisplayLocation {
x: u32::from(zoom.cell_pan_from_left) * u32::from(font_size.0),
y: u32::from(zoom.cell_pan_from_top) * u32::from(font_size.1),
width,
height,
columns: img_area.width,
rows: img_area.height,
..DisplayLocation::default()
}
}]);
}
};
// here we calculate how many pages can fit in the available area.
let mut test_area_w = img_area.width;
// go through our pages, starting at the first one we want to view
@@ -254,7 +288,7 @@ impl Tui {
take
})
// and map it to their width (in cells on the terminal, not pixels)
.filter_map(|(_, page)| page.img.as_mut().map(|img| (img.rect().width, img)))
.filter_map(|(_, page)| page.img.as_mut().map(|img| (img.w_h().0, img)))
// and then take them as long as they won't overflow the available area.
.take_while(|(width, _)| match test_area_w.checked_sub(*width) {
Some(new_val) => {
@@ -272,6 +306,7 @@ impl Tui {
if page_widths.is_empty() {
// If none are ready to render, just show the loading thing
Self::render_loading_in(frame, img_area);
KittyDisplay::ClearImages
} else {
execute!(stdout(), BeginSynchronizedUpdate).unwrap();
@@ -283,20 +318,50 @@ impl Tui {
self.last_render.unused_width = unused_width;
img_area.x += unused_width / 2;
for (width, img) in page_widths {
Self::render_single_page(frame, img, Rect { width, ..img_area });
img_area.x += width;
}
let to_display = page_widths
.into_iter()
.enumerate()
.filter_map(|(idx, (width, img))| {
let maybe_img =
Self::render_single_page(frame, img, Rect { width, ..img_area });
img_area.x += width;
maybe_img.map(|(img, pos)| KittyReadyToDisplay {
img,
page_num: idx + self.page,
pos,
display_loc: DisplayLocation::default()
})
})
.collect::<Vec<_>>();
// we want to set this at the very end so it doesn't get set somewhere halfway through and
// then the whole diffing thing messes it up
self.last_render.rect = size;
KittyDisplay::DisplayImages(to_display)
}
}
}
fn render_single_page(frame: &mut Frame<'_>, page_img: &mut Protocol, img_area: Rect) {
frame.render_widget(Image::new(page_img), img_area);
fn render_single_page<'img>(
frame: &mut Frame<'_>,
page_img: &'img mut ConvertedImage,
img_area: Rect
) -> Option<(&'img mut MaybeTransferred, Position)> {
match page_img {
ConvertedImage::Generic(page_img) => {
frame.render_widget(Image::new(page_img), img_area);
None
}
ConvertedImage::Kitty {
img,
cell_h: _,
cell_w: _
} => Some((img, Position {
x: img_area.x,
y: img_area.y
}))
}
}
fn render_loading_in(frame: &mut Frame<'_>, area: Rect) {
@@ -329,7 +394,8 @@ impl Tui {
let old = self.page;
match change {
PageChange::Next => self.set_page((self.page + diff).min(self.rendered.len() - 1)),
PageChange::Next =>
self.set_page((self.page + diff).min(self.rendered.len().saturating_sub(1))),
PageChange::Prev => self.set_page(self.page.saturating_sub(diff))
}
@@ -344,7 +410,7 @@ impl Tui {
self.page = self.page.min(n_pages - 1);
}
pub fn page_ready(&mut self, img: Protocol, page_num: usize, num_results: usize) {
pub fn page_ready(&mut self, img: ConvertedImage, page_num: usize, num_results: usize) {
// If this new image woulda fit within the available space on the last render AND it's
// within the range where it might've been rendered with the last shown pages, then reset
// the last rect marker so that all images are forced to redraw on next render and this one
@@ -352,7 +418,7 @@ impl Tui {
if page_num >= self.page && page_num <= self.page + self.last_render.pages_shown {
self.last_render.rect = Rect::default();
} else {
let img_w = img.rect().width;
let img_w = img.w_h().0;
if img_w <= self.last_render.unused_width {
let num_fit = self.last_render.unused_width / img_w;
if page_num >= self.page && (self.page + num_fit as usize) >= page_num {
@@ -370,10 +436,101 @@ impl Tui {
};
}
pub fn page_failed_display(&mut self, page_num: usize) {
self.rendered[page_num].img = None;
}
pub fn got_num_results_on_page(&mut self, page_num: usize, num_results: usize) {
self.rendered[page_num].num_results = Some(num_results);
}
pub fn render_top_and_bottom(
(top_area, bottom_area): (Rect, Rect),
page_num: usize,
rendered: &[RenderedInfo],
doc_name: &str,
frame: &mut Frame<'_>,
bottom_msg: &BottomMessage
) {
// use the extra space here to add some padding to the right side
let page_nums_text = format!("{} / {} ", page_num + 1, rendered.len());
let top_block = Block::new()
// use this first title to add a bit of padding to the left side
.title_top(" ")
.title_top(Span::styled(doc_name, Style::new().fg(Color::Cyan)))
.title_top(
Span::styled(&page_nums_text, Style::new().fg(Color::Cyan))
.into_right_aligned_line()
)
.padding(Padding {
bottom: 1,
..Padding::default()
})
.borders(Borders::BOTTOM);
frame.render_widget(top_block, top_area);
let bottom_block = Block::new()
.padding(Padding {
top: 1,
right: 2,
left: 2,
bottom: 0
})
.borders(Borders::TOP);
let bottom_inside_block = bottom_block.inner(bottom_area);
frame.render_widget(bottom_block, bottom_area);
let rendered_str = if !rendered.is_empty() {
format!(
"Rendered: {}%",
(rendered.iter().filter(|i| i.img.is_some()).count() * 100) / rendered.len()
)
} else {
String::new()
};
let bottom_layout = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(rendered_str.len() as u16)
])
.split(bottom_inside_block);
let rendered_span = Span::styled(&rendered_str, Style::new().fg(Color::Cyan));
frame.render_widget(rendered_span, bottom_layout[1]);
let (msg_str, color): (Cow<'_, str>, _) = match bottom_msg {
BottomMessage::Help => ("?: Show help page".into(), Color::Blue),
BottomMessage::Error(e) => (e.as_str().into(), Color::Red),
BottomMessage::Input(input_state) => (
match input_state {
InputCommand::GoToPage(page) => format!("Go to: {page}"),
InputCommand::Search(s) => format!("Search: {s}")
}
.into(),
Color::Blue
),
BottomMessage::SearchResults(term) => {
let num_found = rendered.iter().filter_map(|r| r.num_results).sum::<usize>();
let num_searched =
rendered.iter().filter(|r| r.num_results.is_some()).count() * 100;
(
format!(
"Results for '{term}': {num_found} (searched: {}%)",
num_searched / rendered.len()
)
.into(),
Color::Blue
)
}
BottomMessage::Reloaded => ("Document was reloaded!".into(), Color::Blue)
};
let span = Span::styled(msg_str, Style::new().fg(color));
frame.render_widget(span, bottom_layout[0]);
}
pub fn handle_event(&mut self, ev: &Event) -> Option<InputAction> {
fn jump_to_page(
page: &mut usize,
@@ -467,7 +624,8 @@ impl Tui {
execute!(
&mut backend,
LeaveAlternateScreen,
crossterm::cursor::Show
crossterm::cursor::Show,
crossterm::event::DisableMouseCapture
)
.unwrap();
disable_raw_mode().unwrap();
@@ -481,13 +639,40 @@ impl Tui {
execute!(
&mut backend,
EnterAlternateScreen,
crossterm::cursor::Hide
crossterm::cursor::Hide,
crossterm::event::EnableMouseCapture
)
.unwrap();
self.last_render.rect = Rect::default();
Some(InputAction::Redraw)
}
'z' if self.is_kitty => {
let (zoom, f_or_f) = match self.zoom {
None => (Some(Zoom::default()), FitOrFill::Fill),
Some(_) => (None, FitOrFill::Fit)
};
self.zoom = zoom;
self.last_render.rect = Rect::default();
Some(InputAction::SwitchRenderZoom(f_or_f))
}
'o' if self.is_kitty => self.update_zoom(|z|
// TODO: for now, we don't let people zoom in past fill-screen
z.level = z.level.saturating_add(1).min(0)),
'O' if self.is_kitty =>
self.update_zoom(|z| z.level = z.level.saturating_sub(1)),
'L' if self.is_kitty => self.update_zoom(|z| {
z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1)
}),
'H' if self.is_kitty => self.update_zoom(|z| {
z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1)
}),
'J' if self.is_kitty => self.update_zoom(|z| {
z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1)
}),
'K' if self.is_kitty => self.update_zoom(|z| {
z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1)
}),
_ => None
}
}
@@ -571,22 +756,47 @@ impl Tui {
_ => None
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollRight =>
self.change_page(PageChange::Next, ChangeAmount::Single),
MouseEventKind::ScrollDown =>
self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
MouseEventKind::ScrollLeft =>
self.change_page(PageChange::Prev, ChangeAmount::Single),
MouseEventKind::ScrollUp =>
self.change_page(PageChange::Prev, ChangeAmount::WholeScreen),
_ => None
},
Event::Mouse(mouse) => {
if mouse.modifiers.contains(KeyModifiers::CONTROL)
&& self.is_kitty
&& self.zoom.is_some()
{
match mouse.kind {
MouseEventKind::ScrollUp =>
self.update_zoom(|z| z.level = z.level.saturating_add(1).min(0)),
MouseEventKind::ScrollDown =>
self.update_zoom(|z| z.level = z.level.saturating_sub(1)),
_ => None
}
} else {
match mouse.kind {
MouseEventKind::ScrollRight =>
self.change_page(PageChange::Next, ChangeAmount::Single),
MouseEventKind::ScrollDown =>
self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
MouseEventKind::ScrollLeft =>
self.change_page(PageChange::Prev, ChangeAmount::Single),
MouseEventKind::ScrollUp =>
self.change_page(PageChange::Prev, ChangeAmount::WholeScreen),
_ => None
}
}
}
Event::Resize(_, _) => Some(InputAction::Redraw),
_ => None
}
}
// I want this to always return 0 'cause I just use it to return from `Self::handle_event`]
#[expect(clippy::unnecessary_wraps)]
fn update_zoom(&mut self, f: impl FnOnce(&mut Zoom)) -> Option<InputAction> {
if let Some(z) = &mut self.zoom {
f(z)
}
self.last_render.rect = Rect::default();
Some(InputAction::Redraw)
}
pub fn show_error(&mut self, err: RenderError) {
self.set_msg(MessageSetting::Some(BottomMessage::Error(match err {
RenderError::Notify(e) => format!("Auto-reload failed: {e}"),
@@ -637,7 +847,7 @@ impl Tui {
.border_set(border::ROUNDED)
.border_style(Color::Blue);
let help_span = Text::raw(HELP_PAGE);
let help_span = Paragraph::new(HELP_PAGE).wrap(Wrap { trim: false });
let max_w: u16 = HELP_PAGE
.lines()
@@ -670,25 +880,31 @@ impl Tui {
static HELP_PAGE: &str = "\
l, h, left, right:
Go forward/backwards a single page
Go forward/backwards a single page
j, k, down, up:
Go forwards/backwards a screen's worth of pages
Go forwards/backwards a screen's worth of pages
q, esc:
Quit
Quit
g:
Go to specific page (type numbers after 'g')
Go to specific page (type numbers after 'g')
/:
Search
Search
n, N:
Next/Previous search result
Next/Previous search result
i:
Invert colors
Invert colors
f:
Remove borders/fullscreen
Remove borders/fullscreen
z (when using kitty protocol):
Toggle between fill-screen and fit-screen
o/O (when on fill-screen):
zoom in and out, respectively
H, J, K, L (when zoomed in):
pan direction around page
?:
Show this page
Show this page
ctrl+z:
Suspend & background tdf \
Suspend & background tdf \
";
pub enum InputAction {
@@ -697,7 +913,8 @@ pub enum InputAction {
Search(String),
QuitApp,
Invert,
Fullscreen
Fullscreen,
SwitchRenderZoom(crate::FitOrFill)
}
#[derive(Copy, Clone)]