From b09ce88d9f3fddf5e9057a88dfaa69907fed902c Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sat, 31 May 2025 17:22:36 -0600 Subject: [PATCH 01/22] Initial attempt at supporting new backend for kitty images --- Cargo.lock | 36 ++++++++++++++++++ Cargo.toml | 2 + src/converter.rs | 88 +++++++++++++++++++++++++++++++++++++------ src/main.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++--- src/tui.rs | 56 ++++++++++++++++++++------- 5 files changed, 251 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2626049..c20758e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1518,6 +1518,22 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "kittage" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "crossterm", + "futures-core", + "image", + "memchr", + "memmap2", + "psx-shm", + "rustix 1.0.8", + "thiserror 2.0.12", + "tokio", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1698,6 +1714,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memmap2" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -2291,6 +2316,15 @@ dependencies = [ "prost", ] +[[package]] +name = "psx-shm" +version = "0.1.1" +source = "git+https://github.com/itsjunetime/psx-shm.git#3fcbae91217cd50ea0e4c838276ef7500cccf024" +dependencies = [ + "memmap2", + "rustix 1.0.8", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -2907,6 +2941,8 @@ dependencies = [ "futures-util", "image", "itertools 0.14.0", + "kittage", + "memmap2", "mimalloc", "mupdf", "nix 0.30.1", diff --git a/Cargo.toml b/Cargo.toml index c2cbaa8..ab3a37f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ mimalloc = "0.1.43" nix = { version = "0.30.0", features = ["signal"] } mupdf = { version = "0.5.0", default-features = false, features = ["svg", "system-fonts", "img"] } rayon = { version = "*", default-features = false } +kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate"] } +memmap2 = "*" # for tracing with tokio-console console-subscriber = { version = "0.4.0", optional = true } diff --git a/src/converter.rs b/src/converter.rs index 27bc4b8..aabf6ca 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -1,15 +1,42 @@ +use std::num::NonZeroU32; + 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}; +#[derive(Debug)] +pub enum MaybeTransferred { + NotYet(kittage::image::Image<'static>, memmap2::MmapMut), + Transferred(kittage::ImageId) +} + +pub enum ConvertedImage { + Generic(Protocol), + Kitty { img: MaybeTransferred, area: Rect } +} + +impl ConvertedImage { + pub fn area(&self) -> Rect { + match self { + Self::Generic(prot) => prot.area(), + Self::Kitty { img: _, area } => *area + } + } +} + pub struct ConvertedPage { - pub page: Protocol, + pub page: ConvertedImage, pub num: usize, pub num_results: usize } @@ -28,13 +55,15 @@ pub async fn run_conversion_loop( ) -> Result<(), SendError>> { let mut images = vec![]; let mut page: usize = 0; + let pid = std::process::id(); fn next_page( images: &mut [Option], picker: &mut Picker, page: usize, iteration: &mut usize, - prerender: usize + prerender: usize, + pid: u32 ) -> Result, RenderError> { if images.is_empty() || *iteration >= prerender { return Ok(None); @@ -85,13 +114,43 @@ pub async fn run_conversion_loop( // 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 area = ratatui_image::protocol::ImageSource::round_pixel_size_to_cells( + dyn_img.width(), + dyn_img.height(), + picker.font_size() + ); + + match kittage::image::Image::shm_from( + dyn_img, + format!("__tdf_kittage_{pid}_page_{page}").into() + ) { + Ok((mut img, map)) => { + img.num_or_id = NumberOrId::Id(NonZeroU32::new(page as u32 + 1).unwrap()); + ConvertedImage::Kitty { + img: MaybeTransferred::NotYet(img, map), + area + } + } + // todo: fallback to non-shm image here without cloning dyn_img above + // Err(_) => ConvertedImage::Kitty(dyn_img.into()) + Err(e) => + return Err(RenderError::Converting(format!( + "Couldn't write to shm: {e}" + ))), + } + } + _ => 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}" + )) + })? + ) + }; // update the iteration to the iteration that we stole this image from *iteration = new_iter; @@ -130,7 +189,14 @@ 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 + ) { Ok(None) => break, Ok(Some(img)) => sender.send(Ok(img))?, Err(e) => sender.send(Err(e))? diff --git a/src/main.rs b/src/main.rs index 6984002..448b713 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,8 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{BufReader, Read, Stdout, Write, stdout}, - num::NonZeroUsize, + io::{BufReader, Read, StdoutLock, Write, stdout}, + num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -17,12 +17,20 @@ use crossterm::{ }; use flume::{Sender, r#async::RecvStream}; use futures_util::{FutureExt, stream::StreamExt}; +use kittage::{ + ImageDimensions, PixelFormat, + action::Action, + display::{DisplayConfig, DisplayLocation}, + error::TransmitError, + image::Image as KImage, + medium::Medium +}; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui_image::picker::Picker; use tdf::{ PrerenderLimit, - converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, + converter::{ConvertedPage, ConverterMsg, MaybeTransferred, run_conversion_loop}, renderer::{self, RenderError, RenderInfo, RenderNotif}, tui::{BottomMessage, InputAction, MessageSetting, Tui} }; @@ -373,10 +381,90 @@ async fn enter_redraw_loop( } if needs_redraw { + let mut to_display = vec![]; term.draw(|f| { - tui.render(f, &main_area); + to_display = tui.render(f, &main_area); })?; - execute!(stdout(), EndSynchronizedUpdate)?; + + let mut stdout = stdout().lock(); + let mut maybe_err = Ok(()); + for (img, area) in to_display { + let config = DisplayConfig { + location: DisplayLocation { + x: area.x.into(), + y: area.y.into(), + ..DisplayLocation::default() + }, + ..DisplayConfig::default() + }; + + maybe_err = match img { + MaybeTransferred::NotYet(image, _map) => { + let mut fake_image = KImage { + 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 = Action::TransmitAndDisplay { + image: fake_image, + config, + placement_id: None + } + .execute_async(&mut stdout, &mut ev_stream) + .await; + + match res { + Ok((_, img_id)) => { + // We need the `_map` to be dropped here, but can't explicitly carry it + // over to here. So we're just relying on the overwrite of `img` to + // drop `_map` (and thus unmap the memory) for us + *img = MaybeTransferred::Transferred(img_id); + Ok(()) + } + Err(e) => Err(match e { + TransmitError::Writing( + Action::TransmitAndDisplay { + image: failed_img, .. + }, + e + ) => { + *image = failed_img; + e.to_string() + } + _ => e.to_string() + }) + } + } + MaybeTransferred::Transferred(image_id) => Action::Display { + image_id: *image_id, + placement_id: NonZeroU32::new(1).unwrap(), + config + } + .execute_async(&mut stdout, &mut ev_stream) + .await + .map(|(_, _)| ()) + .map_err(|e| e.to_string()) + }; + } + + if let Err(e) = maybe_err { + tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( + "Couldn't transfer image to the terminal: {e}" + )))); + } + + execute!(&mut stdout, EndSynchronizedUpdate)?; } } } diff --git a/src/tui.rs b/src/tui.rs index 9667e04..63cf72c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -20,9 +20,10 @@ use ratatui::{ text::{Span, Text}, widgets::{Block, Borders, Clear, Padding} }; -use ratatui_image::{Image, protocol::Protocol}; +use ratatui_image::Image; use crate::{ + converter::{ConvertedImage, MaybeTransferred}, renderer::{RenderError, fill_default}, skip::Skip }; @@ -74,7 +75,7 @@ struct PageConstraints { #[derive(Default)] struct RenderedInfo { // The image, if it has been rendered by `Converter` to that struct - img: Option, + img: Option, // 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 @@ -127,10 +128,15 @@ 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 + ) -> Vec<(&'s mut MaybeTransferred, Rect)> { if self.showing_help_msg { self.render_help_msg(frame); - return; + return vec![]; } if let Some((top_area, bottom_area)) = full_layout.top_and_bottom { @@ -237,6 +243,7 @@ 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); + vec![] } else { // here we calculate how many pages can fit in the available area. let mut test_area_w = img_area.width; @@ -254,7 +261,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.area().width, 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 +279,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); + vec![] } else { execute!(stdout(), BeginSynchronizedUpdate).unwrap(); @@ -283,20 +291,42 @@ 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() + .filter_map(|(width, img)| { + let maybe_img = + Self::render_single_page(frame, img, Rect { width, ..img_area }); + img_area.x += width; + maybe_img + }) + .collect::>(); // 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; + + 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, Rect)> { + match page_img { + ConvertedImage::Generic(page_img) => { + frame.render_widget(Image::new(page_img), img_area); + None + } + ConvertedImage::Kitty { img, area } => Some((img, Rect { + x: img_area.x, + y: img_area.y, + width: area.width, + height: area.height + })) + } } fn render_loading_in(frame: &mut Frame<'_>, area: Rect) { @@ -344,7 +374,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 +382,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.area().width; 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 { From 4d764cd4f9be25d223e06809ff232bfd475490af Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Mon, 2 Jun 2025 17:41:04 -0600 Subject: [PATCH 02/22] it's almost working !! --- Cargo.lock | 140 +++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 5 ++ src/converter.rs | 9 +-- src/kitty.rs | 48 ++++++++++++++++ src/lib.rs | 1 + src/main.rs | 125 ++++++++++++++++++++++++++++++++---------- src/tui.rs | 11 +++- 7 files changed, 298 insertions(+), 41 deletions(-) create mode 100644 src/kitty.rs diff --git a/Cargo.lock b/Cargo.lock index c20758e..df2ee21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -386,6 +401,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -968,6 +995,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flexi_logger" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb03342077df16d5b1400d7bed00156882846d7a479ff61a6f10594bcc3423d8" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.12", +] + [[package]] name = "float-ord" version = "0.3.2" @@ -1331,6 +1371,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icy_sixel" version = "0.1.3" @@ -1880,6 +1944,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2937,11 +3010,13 @@ dependencies = [ "criterion", "crossterm", "csscolorparser 0.7.2", + "flexi_logger", "flume", "futures-util", "image", "itertools 0.14.0", "kittage", + "log", "memmap2", "mimalloc", "mupdf", @@ -3627,7 +3702,7 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "windows-core", + "windows-core 0.58.0", "windows-targets 0.52.6", ] @@ -3637,13 +3712,26 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-strings", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -3655,6 +3743,17 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "windows-interface" version = "0.58.0" @@ -3666,6 +3765,17 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -3681,16 +3791,34 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index ab3a37f..a62de67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ 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"] } @@ -43,6 +44,10 @@ rayon = { version = "*", default-features = false } kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate"] } memmap2 = "*" +# logging +log = "0.4.27" +flexi_logger = "0.30.2" + # for tracing with tokio-console console-subscriber = { version = "0.4.0", optional = true } csscolorparser = { version = "0.7.0" } diff --git a/src/converter.rs b/src/converter.rs index aabf6ca..081be64 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -76,11 +76,11 @@ pub async fn run_conversion_loop( // 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) + let Some((page_info, new_iter, page_num)) = (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))) + .find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx, p_idx))) else { return Ok(None); }; @@ -124,10 +124,11 @@ pub async fn run_conversion_loop( match kittage::image::Image::shm_from( dyn_img, - format!("__tdf_kittage_{pid}_page_{page}").into() + format!("__tdf_kittage_{pid}_page_{page_num}").into() ) { Ok((mut img, map)) => { - img.num_or_id = NumberOrId::Id(NonZeroU32::new(page as u32 + 1).unwrap()); + img.num_or_id = + NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap()); ConvertedImage::Kitty { img: MaybeTransferred::NotYet(img, map), area diff --git a/src/kitty.rs b/src/kitty.rs new file mode 100644 index 0000000..c4c7428 --- /dev/null +++ b/src/kitty.rs @@ -0,0 +1,48 @@ +use std::io::Write; + +use crossterm::event::EventStream; +use kittage::{AsyncInputReader, ImageId, action::Action, error::TransmitError}; + +#[derive(Debug)] +pub struct DbgWriter { + w: W, + #[cfg(debug_assertions)] + buf: String +} + +impl Write for DbgWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + #[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!("wrote {:?}", 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::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) +} diff --git a/src/lib.rs b/src/lib.rs index 1da041b..a93e08d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub enum PrerenderLimit { } pub mod converter; +pub mod kitty; pub mod renderer; pub mod skip; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 448b713..4e1ed0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{BufReader, Read, StdoutLock, Write, stdout}, + io::{BufReader, Read, Write, stdout}, num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -16,10 +16,12 @@ use crossterm::{ } }; use flume::{Sender, r#async::RecvStream}; +use flexi_logger::FileSpec; use futures_util::{FutureExt, stream::StreamExt}; use kittage::{ ImageDimensions, PixelFormat, action::Action, + delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, display::{DisplayConfig, DisplayLocation}, error::TransmitError, image::Image as KImage, @@ -27,10 +29,11 @@ use kittage::{ }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; -use ratatui_image::picker::Picker; +use ratatui_image::picker::{Picker, ProtocolType}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, MaybeTransferred, run_conversion_loop}, + kitty::run_action, renderer::{self, RenderError, RenderInfo, RenderNotif}, tui::{BottomMessage, InputAction, MessageSetting, Tui} }; @@ -97,6 +100,23 @@ 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}")))? + .log_to_file(FileSpec::try_from("./debug.log").map_err(|e| { + WrappedErr(format!("Couldn't create FileSpec for logger: {e}")) + })?) + .start() + .map_err(|e| WrappedErr(format!("Can't start logger: {e}")))? + ); + } + let (watch_to_render_tx, render_rx) = flume::unbounded(); let tui_tx = watch_to_render_tx.clone(); @@ -233,6 +253,7 @@ async fn main() -> Result<(), WrappedErr> { let (to_converter, from_main) = flume::unbounded(); let (to_main, from_converter) = flume::unbounded(); + let is_kitty = picker.protocol_type() == ProtocolType::Kitty; tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20)); let file_name = path.file_name().map_or_else( @@ -266,6 +287,17 @@ async fn main() -> Result<(), WrappedErr> { ) })?; + if is_kitty { + run_action( + Action::Delete(DeleteConfig { + effect: ClearOrDelete::Delete, + which: WhichToDelete::IdRange(NonZeroU32::new(1).unwrap()..=NonZeroU32::MAX) + }), + &mut ev_stream + ) + .await?; + } + let fullscreen = flags.fullscreen.unwrap_or_default(); let main_area = Tui::main_layout(&term.get_frame(), fullscreen); tui_tx @@ -326,9 +358,10 @@ async fn enter_redraw_loop( ) -> Result<(), Box> { 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"); @@ -386,9 +419,9 @@ async fn enter_redraw_loop( to_display = tui.render(f, &main_area); })?; - let mut stdout = stdout().lock(); let mut maybe_err = Ok(()); - for (img, area) in to_display { + let mut to_replace = Vec::new(); + for (page_num, img, area) in to_display { let config = DisplayConfig { location: DisplayLocation { x: area.x.into(), @@ -398,6 +431,8 @@ async fn enter_redraw_loop( ..DisplayConfig::default() }; + log::debug!("looking at img {img:#?}"); + maybe_err = match img { MaybeTransferred::NotYet(image, _map) => { let mut fake_image = KImage { @@ -416,16 +451,22 @@ async fn enter_redraw_loop( }; std::mem::swap(image, &mut fake_image); - let res = Action::TransmitAndDisplay { - image: fake_image, - config, - placement_id: None - } - .execute_async(&mut stdout, &mut ev_stream) + log::debug!("Actually trying to display an image now: {fake_image:?}..."); + + let res = run_action( + Action::TransmitAndDisplay { + image: fake_image, + config, + placement_id: None + }, + &mut ev_stream + ) .await; + log::debug!("And it should've gone through: {res:?}!..."); + match res { - Ok((_, img_id)) => { + Ok(img_id) => { // We need the `_map` to be dropped here, but can't explicitly carry it // over to here. So we're just relying on the overwrite of `img` to // drop `_map` (and thus unmap the memory) for us @@ -433,40 +474,68 @@ async fn enter_redraw_loop( Ok(()) } Err(e) => Err(match e { - TransmitError::Writing( - Action::TransmitAndDisplay { + TransmitError::Writing(action, e) => { + if let Action::TransmitAndDisplay { image: failed_img, .. - }, - e - ) => { - *image = failed_img; + } = *action + { + *image = failed_img; + } else { + to_replace.push(page_num); + } + + e.to_string() + } + _ => { + to_replace.push(page_num); e.to_string() } - _ => e.to_string() }) } } - MaybeTransferred::Transferred(image_id) => Action::Display { - image_id: *image_id, - placement_id: NonZeroU32::new(1).unwrap(), - config + MaybeTransferred::Transferred(image_id) => { + let e = run_action( + Action::Display { + image_id: *image_id, + placement_id: NonZeroU32::new(1).unwrap(), + config + }, + &mut ev_stream + ) + .await + .map(|_| ()) + .map_err(|e| e.to_string()); + + log::debug!("Just tried to display: {e:?}"); + e } - .execute_async(&mut stdout, &mut ev_stream) - .await - .map(|(_, _)| ()) - .map_err(|e| e.to_string()) }; } + for page_num in to_replace { + tui.page_failed_display(page_num); + } + if let Err(e) = maybe_err { tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( "Couldn't transfer image to the terminal: {e}" )))); } - execute!(&mut stdout, EndSynchronizedUpdate)?; + execute!(stdout().lock(), EndSynchronizedUpdate)?; } } + + execute!( + term.backend_mut(), + LeaveAlternateScreen, + crossterm::cursor::Show + )?; + disable_raw_mode()?; + + drop(maybe_logger); + + Ok(()) } fn on_notify_ev( diff --git a/src/tui.rs b/src/tui.rs index 63cf72c..34fa2ec 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -133,7 +133,7 @@ impl Tui { &'s mut self, frame: &mut Frame<'_>, full_layout: &RenderLayout - ) -> Vec<(&'s mut MaybeTransferred, Rect)> { + ) -> Vec<(usize, &'s mut MaybeTransferred, Rect)> { if self.showing_help_msg { self.render_help_msg(frame); return vec![]; @@ -293,11 +293,12 @@ impl Tui { let to_display = page_widths .into_iter() - .filter_map(|(width, img)| { + .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 + maybe_img.map(|(img, r)| (idx + self.page, img, r)) }) .collect::>(); @@ -400,6 +401,10 @@ 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); } From 4bde532d0849b30b1b1b7f4497e909b29350188e Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Mon, 2 Jun 2025 22:33:12 -0600 Subject: [PATCH 03/22] it almost basically works --- benches/utils.rs | 5 +- src/kitty.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 120 +++++-------------------------------------- src/renderer.rs | 8 ++- 4 files changed, 147 insertions(+), 115 deletions(-) diff --git a/benches/utils.rs b/benches/utils.rs index 28d847e..7267bad 100644 --- a/benches/utils.rs +++ b/benches/utils.rs @@ -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 diff --git a/src/kitty.rs b/src/kitty.rs index c4c7428..8bb730e 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -1,7 +1,18 @@ -use std::io::Write; +use std::{io::Write, num::NonZeroU32}; -use crossterm::event::EventStream; -use kittage::{AsyncInputReader, ImageId, action::Action, error::TransmitError}; +use crossterm::{cursor::MoveTo, event::EventStream, execute}; +use kittage::{ + AsyncInputReader, ImageDimensions, ImageId, PixelFormat, + action::Action, + delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, + display::DisplayConfig, + error::TransmitError, + image::Image, + medium::Medium +}; +use ratatui::prelude::Rect; + +use crate::converter::MaybeTransferred; #[derive(Debug)] pub struct DbgWriter { @@ -46,3 +57,115 @@ pub async fn run_action<'image, 'data, 'es>( .await .map(|(_, i)| i) } + +pub async fn display_kitty_images( + images: Vec<(usize, &mut MaybeTransferred, Rect)>, + ev_stream: &mut EventStream +) -> Result<(), (Vec, String)> { + run_action( + Action::Delete(DeleteConfig { + effect: ClearOrDelete::Clear, + which: WhichToDelete::All + }), + ev_stream + ) + .await + .map_err(|e| (vec![], format!("Couldn't clear previous images: {e}")))?; + + let mut err = None; + for (page_num, img, area) in images { + let config = DisplayConfig::default(); + + execute!(std::io::stdout(), MoveTo(area.x, area.y)).unwrap(); + + log::debug!("looking at (area {area:?}) img {img:#?}"); + + let this_err = match img { + MaybeTransferred::NotYet(image, _map) => { + 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); + + log::debug!("Actually trying to display an image now: {fake_image:?}..."); + + let res = run_action( + Action::TransmitAndDisplay { + image: fake_image, + config, + placement_id: None + }, + ev_stream + ) + .await; + + log::debug!("And it should've gone through: {res:?}!..."); + + match res { + Ok(img_id) => { + // We need the `_map` to be dropped here, but can't explicitly carry it + // over to here. So we're just relying on the overwrite of `img` to + // drop `_map` (and thus unmap the memory) for us + *img = MaybeTransferred::Transferred(img_id); + Ok(()) + } + Err(e) => Err(match e { + TransmitError::Writing(action, e) => { + let num = if let Action::TransmitAndDisplay { + image: failed_img, .. + } = *action + { + *image = failed_img; + None + } else { + Some(page_num) + }; + + (num, e.to_string()) + } + _ => (Some(page_num), e.to_string()) + }) + } + } + MaybeTransferred::Transferred(image_id) => { + let e = run_action( + Action::Display { + image_id: *image_id, + placement_id: NonZeroU32::new(1).unwrap(), + config + }, + ev_stream + ) + .await + .map(|_| ()) + .map_err(|e| (None, e.to_string())); + + log::debug!("Just tried to display: {e:?}"); + e + } + }; + + if let Err((id, e)) = this_err { + let e = err.get_or_insert_with(|| (vec![], e)); + if let Some(id) = id { + e.0.push(id); + } + } + } + + match err { + Some(e) => Err(e), + None => Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 4e1ed0a..aec99d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,21 +19,16 @@ use flume::{Sender, r#async::RecvStream}; use flexi_logger::FileSpec; use futures_util::{FutureExt, stream::StreamExt}; use kittage::{ - ImageDimensions, PixelFormat, action::Action, - delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, - display::{DisplayConfig, DisplayLocation}, - error::TransmitError, - image::Image as KImage, - medium::Medium + delete::{ClearOrDelete, DeleteConfig, WhichToDelete} }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui_image::picker::{Picker, ProtocolType}; use tdf::{ PrerenderLimit, - converter::{ConvertedPage, ConverterMsg, MaybeTransferred, run_conversion_loop}, - kitty::run_action, + converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, + kitty::{display_kitty_images, run_action}, renderer::{self, RenderError, RenderInfo, RenderNotif}, tui::{BottomMessage, InputAction, MessageSetting, Tui} }; @@ -236,12 +231,16 @@ async fn main() -> Result<(), WrappedErr> { .prerender .and_then(NonZeroUsize::new) .map_or(PrerenderLimit::All, PrerenderLimit::Limited); + + let cell_height_px = window_size.height / window_size.rows; + let cell_width_px = window_size.width / window_size.columns; std::thread::spawn(move || { renderer::start_rendering( &file_path, render_tx, render_rx, - window_size, + cell_height_px, + cell_width_px, prerender, black, white @@ -419,107 +418,16 @@ async fn enter_redraw_loop( to_display = tui.render(f, &main_area); })?; - let mut maybe_err = Ok(()); - let mut to_replace = Vec::new(); - for (page_num, img, area) in to_display { - let config = DisplayConfig { - location: DisplayLocation { - x: area.x.into(), - y: area.y.into(), - ..DisplayLocation::default() - }, - ..DisplayConfig::default() - }; - - log::debug!("looking at img {img:#?}"); - - maybe_err = match img { - MaybeTransferred::NotYet(image, _map) => { - let mut fake_image = KImage { - 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); - - log::debug!("Actually trying to display an image now: {fake_image:?}..."); - - let res = run_action( - Action::TransmitAndDisplay { - image: fake_image, - config, - placement_id: None - }, - &mut ev_stream - ) - .await; - - log::debug!("And it should've gone through: {res:?}!..."); - - match res { - Ok(img_id) => { - // We need the `_map` to be dropped here, but can't explicitly carry it - // over to here. So we're just relying on the overwrite of `img` to - // drop `_map` (and thus unmap the memory) for us - *img = MaybeTransferred::Transferred(img_id); - Ok(()) - } - Err(e) => Err(match e { - TransmitError::Writing(action, e) => { - if let Action::TransmitAndDisplay { - image: failed_img, .. - } = *action - { - *image = failed_img; - } else { - to_replace.push(page_num); - } - - e.to_string() - } - _ => { - to_replace.push(page_num); - e.to_string() - } - }) - } - } - MaybeTransferred::Transferred(image_id) => { - let e = run_action( - Action::Display { - image_id: *image_id, - placement_id: NonZeroU32::new(1).unwrap(), - config - }, - &mut ev_stream - ) - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - - log::debug!("Just tried to display: {e:?}"); - e - } - }; - } + let maybe_err = display_kitty_images(to_display, &mut ev_stream).await; - for page_num in to_replace { - tui.page_failed_display(page_num); - } - - if let Err(e) = maybe_err { + if let Err((to_replace, e)) = maybe_err { tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( "Couldn't transfer image to the terminal: {e}" )))); + + for page_num in to_replace { + tui.page_failed_display(page_num); + } } execute!(stdout().lock(), EndSynchronizedUpdate)?; diff --git a/src/renderer.rs b/src/renderer.rs index 173a84b..708eb25 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,6 +1,5 @@ use std::{thread::sleep, time::Duration}; -use crossterm::terminal::WindowSize; use flume::{Receiver, SendError, Sender, TryRecvError}; use itertools::Itertools; use mupdf::{ @@ -78,7 +77,8 @@ pub fn start_rendering( path: &str, sender: Sender>, receiver: Receiver, - size: WindowSize, + col_h: u16, + col_w: u16, prerender: PrerenderLimit, black: i32, white: i32 @@ -88,9 +88,7 @@ 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; From fcea5ac6966d9fca0ad5d1b8af94cc24dc0fffdd Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Mon, 2 Jun 2025 23:07:02 -0600 Subject: [PATCH 04/22] yaaayyyy it works --- src/kitty.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/kitty.rs b/src/kitty.rs index 8bb730e..e45ab98 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -1,4 +1,4 @@ -use std::{io::Write, num::NonZeroU32}; +use std::io::Write; use crossterm::{cursor::MoveTo, event::EventStream, execute}; use kittage::{ @@ -62,6 +62,10 @@ pub async fn display_kitty_images( images: Vec<(usize, &mut MaybeTransferred, Rect)>, ev_stream: &mut EventStream ) -> Result<(), (Vec, String)> { + if images.is_empty() { + return Ok(()); + } + run_action( Action::Delete(DeleteConfig { effect: ClearOrDelete::Clear, @@ -142,7 +146,7 @@ pub async fn display_kitty_images( let e = run_action( Action::Display { image_id: *image_id, - placement_id: NonZeroU32::new(1).unwrap(), + placement_id: *image_id, config }, ev_stream From b791b55b80d8c43fcddd3881e221e354ced07c35 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Mon, 2 Jun 2025 23:25:55 -0600 Subject: [PATCH 05/22] Use github kittage --- Cargo.lock | 1 + Cargo.toml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index df2ee21..4a2196c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,6 +1585,7 @@ dependencies = [ [[package]] name = "kittage" version = "0.1.0" +source = "git+https://github.com/itsjunetime/kittage.git#d872c44f7fc1d3a9f5f1efdc710c300f7ea31d9f" dependencies = [ "base64 0.22.1", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index a62de67..6064bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,8 @@ mimalloc = "0.1.43" nix = { version = "0.30.0", features = ["signal"] } mupdf = { version = "0.5.0", default-features = false, features = ["svg", "system-fonts", "img"] } rayon = { version = "*", default-features = false } -kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate"] } +# kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate"] } +kittage = { git = "https://github.com/itsjunetime/kittage.git", features = ["crossterm-tokio", "image-crate"] } memmap2 = "*" # logging From 6677266010770a8039ce8fb6410cfebd1211e17c Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Thu, 5 Jun 2025 16:25:00 -0600 Subject: [PATCH 06/22] Uhhhh various improvements from kittage and psx-shm --- src/converter.rs | 8 ++++---- src/kitty.rs | 24 +++--------------------- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/converter.rs b/src/converter.rs index 081be64..131e333 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -17,7 +17,7 @@ use crate::renderer::{PageInfo, RenderError, fill_default}; #[derive(Debug)] pub enum MaybeTransferred { - NotYet(kittage::image::Image<'static>, memmap2::MmapMut), + NotYet(kittage::image::Image<'static>), Transferred(kittage::ImageId) } @@ -124,13 +124,13 @@ pub async fn run_conversion_loop( match kittage::image::Image::shm_from( dyn_img, - format!("__tdf_kittage_{pid}_page_{page_num}").into() + &format!("__tdf_kittage_{pid}_page_{page_num}") ) { - Ok((mut img, map)) => { + Ok(mut img) => { img.num_or_id = NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap()); ConvertedImage::Kitty { - img: MaybeTransferred::NotYet(img, map), + img: MaybeTransferred::NotYet(img), area } } diff --git a/src/kitty.rs b/src/kitty.rs index e45ab98..688876b 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -45,7 +45,7 @@ impl Write for DbgWriter { pub async fn run_action<'image, 'data, 'es>( action: Action<'image, 'data>, ev_stream: &'es mut EventStream -) -> Result::Error>> +) -> Result::Error>> { let writer = DbgWriter { w: std::io::stdout().lock(), @@ -85,7 +85,7 @@ pub async fn display_kitty_images( log::debug!("looking at (area {area:?}) img {img:#?}"); let this_err = match img { - MaybeTransferred::NotYet(image, _map) => { + MaybeTransferred::NotYet(image) => { let mut fake_image = Image { num_or_id: image.num_or_id, format: PixelFormat::Rgb24( @@ -118,28 +118,10 @@ pub async fn display_kitty_images( match res { Ok(img_id) => { - // We need the `_map` to be dropped here, but can't explicitly carry it - // over to here. So we're just relying on the overwrite of `img` to - // drop `_map` (and thus unmap the memory) for us *img = MaybeTransferred::Transferred(img_id); Ok(()) } - Err(e) => Err(match e { - TransmitError::Writing(action, e) => { - let num = if let Action::TransmitAndDisplay { - image: failed_img, .. - } = *action - { - *image = failed_img; - None - } else { - Some(page_num) - }; - - (num, e.to_string()) - } - _ => (Some(page_num), e.to_string()) - }) + Err(e) => Err((Some(page_num), e.to_string())), } } MaybeTransferred::Transferred(image_id) => { From 751448844121cfccc1f2aed8f2547ac81f84e512 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Thu, 5 Jun 2025 16:48:29 -0600 Subject: [PATCH 07/22] Remove logging --- src/kitty.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/kitty.rs b/src/kitty.rs index 688876b..af8456f 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -35,7 +35,6 @@ impl Write for DbgWriter { fn flush(&mut self) -> std::io::Result<()> { #[cfg(debug_assertions)] { - log::debug!("wrote {:?}", self.buf); self.buf.clear(); } self.w.flush() @@ -82,8 +81,6 @@ pub async fn display_kitty_images( execute!(std::io::stdout(), MoveTo(area.x, area.y)).unwrap(); - log::debug!("looking at (area {area:?}) img {img:#?}"); - let this_err = match img { MaybeTransferred::NotYet(image) => { let mut fake_image = Image { @@ -102,8 +99,6 @@ pub async fn display_kitty_images( }; std::mem::swap(image, &mut fake_image); - log::debug!("Actually trying to display an image now: {fake_image:?}..."); - let res = run_action( Action::TransmitAndDisplay { image: fake_image, @@ -114,8 +109,6 @@ pub async fn display_kitty_images( ) .await; - log::debug!("And it should've gone through: {res:?}!..."); - match res { Ok(img_id) => { *img = MaybeTransferred::Transferred(img_id); @@ -125,7 +118,7 @@ pub async fn display_kitty_images( } } MaybeTransferred::Transferred(image_id) => { - let e = run_action( + run_action( Action::Display { image_id: *image_id, placement_id: *image_id, @@ -135,10 +128,7 @@ pub async fn display_kitty_images( ) .await .map(|_| ()) - .map_err(|e| (None, e.to_string())); - - log::debug!("Just tried to display: {e:?}"); - e + .map_err(|e| (None, e.to_string())) } }; From 5e6857881b290a6aca2a60a300844ef0f3f1c5fa Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 8 Jun 2025 21:10:18 -0600 Subject: [PATCH 08/22] incorporate recovering from deleted images --- .github/workflows/rust.yml | 2 + Cargo.lock | 1 - Cargo.toml | 1 - src/converter.rs | 25 +++++---- src/kitty.rs | 55 +++++++++---------- src/main.rs | 73 +++++++++++++------------ src/renderer.rs | 98 ++++++++++++++++++++-------------- src/skip.rs | 106 +++++++++++++++++++++++++++++++++++++ src/tui.rs | 2 +- 9 files changed, 249 insertions(+), 114 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 61ccf7b..2f8eada 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,6 +32,8 @@ jobs: run: rustup component add clippy rustfmt - name: Clippy run: cargo clippy -- -D warnings + - name: Tests + run: cargo test - name: Check fmt run: cargo fmt -- --check - name: Run tests diff --git a/Cargo.lock b/Cargo.lock index 4a2196c..dd0f51e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3015,7 +3015,6 @@ dependencies = [ "flume", "futures-util", "image", - "itertools 0.14.0", "kittage", "log", "memmap2", diff --git a/Cargo.toml b/Cargo.toml index 6064bf7..7b24149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,6 @@ image = { version = "0.25.1", features = ["pnm", "rayon", "png"], default-featur 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" diff --git a/src/converter.rs b/src/converter.rs index 131e333..1f281a6 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -1,9 +1,8 @@ -use std::num::NonZeroU32; +use std::num::{NonZeroU32, NonZeroUsize}; 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::{ @@ -13,9 +12,11 @@ use ratatui_image::{ }; 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) @@ -74,13 +75,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, page_num)) = (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, p_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); }; diff --git a/src/kitty.rs b/src/kitty.rs index af8456f..a5ce954 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -14,7 +14,6 @@ use ratatui::prelude::Rect; use crate::converter::MaybeTransferred; -#[derive(Debug)] pub struct DbgWriter { w: W, #[cfg(debug_assertions)] @@ -44,8 +43,7 @@ impl Write for DbgWriter { pub async fn run_action<'image, 'data, 'es>( action: Action<'image, 'data>, ev_stream: &'es mut EventStream -) -> Result::Error>> -{ +) -> Result::Error>> { let writer = DbgWriter { w: std::io::stdout().lock(), #[cfg(debug_assertions)] @@ -57,14 +55,17 @@ pub async fn run_action<'image, 'data, 'es>( .map(|(_, i)| i) } -pub async fn display_kitty_images( +pub async fn display_kitty_images<'es>( images: Vec<(usize, &mut MaybeTransferred, Rect)>, - ev_stream: &mut EventStream -) -> Result<(), (Vec, String)> { - if images.is_empty() { - return Ok(()); - } - + ev_stream: &'es mut EventStream +) -> Result< + (), + ( + Vec, + &'static str, + TransmitError<<&'es mut EventStream as AsyncInputReader>::Error> + ) +> { run_action( Action::Delete(DeleteConfig { effect: ClearOrDelete::Clear, @@ -73,7 +74,7 @@ pub async fn display_kitty_images( ev_stream ) .await - .map_err(|e| (vec![], format!("Couldn't clear previous images: {e}")))?; + .map_err(|e| (vec![], "Couldn't clear previous images", e))?; let mut err = None; for (page_num, img, area) in images { @@ -114,34 +115,30 @@ pub async fn display_kitty_images( *img = MaybeTransferred::Transferred(img_id); Ok(()) } - Err(e) => Err((Some(page_num), e.to_string())), + 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| (None, e.to_string())) - } + 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)) }; if let Err((id, e)) = this_err { let e = err.get_or_insert_with(|| (vec![], e)); - if let Some(id) = id { - e.0.push(id); - } + e.0.push(id); } } match err { - Some(e) => Err(e), + Some((replace, e)) => Err((replace, "Couldn't transfer image to the terminal", e)), None => Ok(()) } } diff --git a/src/main.rs b/src/main.rs index aec99d2..61db985 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{BufReader, Read, Write, stdout}, + io::{stdout, BufReader, Read, Stdout, Write}, num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -20,7 +20,8 @@ use flexi_logger::FileSpec; use futures_util::{FutureExt, stream::StreamExt}; use kittage::{ action::Action, - delete::{ClearOrDelete, DeleteConfig, WhichToDelete} + delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, + error::{TerminalError, TransmitError} }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; @@ -103,17 +104,17 @@ async fn main() -> Result<(), WrappedErr> { maybe_logger = Some( flexi_logger::Logger::try_with_env() - .map_err(|e| WrappedErr(format!("Couldn't create initial logger: {e}")))? + .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}")) + WrappedErr(format!("Couldn't create FileSpec for logger: {e}").into()) })?) .start() - .map_err(|e| WrappedErr(format!("Can't start logger: {e}")))? + .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(); @@ -247,7 +248,7 @@ async fn main() -> Result<(), WrappedErr> { ) }); - let ev_stream = crossterm::event::EventStream::new(); + let mut ev_stream = crossterm::event::EventStream::new(); let (to_converter, from_main) = flume::unbounded(); let (to_main, from_converter) = flume::unbounded(); @@ -294,12 +295,13 @@ async fn main() -> Result<(), WrappedErr> { }), &mut ev_stream ) - .await?; + .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( @@ -312,7 +314,7 @@ async fn main() -> Result<(), WrappedErr> { enter_redraw_loop( ev_stream, - tui_tx, + to_renderer, tui_rx, to_converter, from_converter, @@ -339,6 +341,8 @@ async fn main() -> Result<(), WrappedErr> { .unwrap(); disable_raw_mode().unwrap(); + drop(maybe_logger); + Ok(()) } @@ -346,7 +350,7 @@ async fn main() -> Result<(), WrappedErr> { #[expect(clippy::too_many_arguments)] async fn enter_redraw_loop( mut ev_stream: EventStream, - tui_tx: Sender, + to_renderer: Sender, mut tui_rx: RecvStream<'_, Result>, to_converter: Sender, mut from_converter: RecvStream<'_, Result>, @@ -370,11 +374,11 @@ 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, } } @@ -408,7 +412,7 @@ 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; } @@ -418,32 +422,33 @@ async fn enter_redraw_loop( to_display = tui.render(f, &main_area); })?; - let maybe_err = display_kitty_images(to_display, &mut ev_stream).await; - - if let Err((to_replace, e)) = maybe_err { - tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( - "Couldn't transfer image to the terminal: {e}" - )))); + if !to_display.is_empty() { + 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 provides us when it deletes an image due to + // memory constraints, so if we get it, we just fix it by re-rendering and + // don't display it to the user + TransmitError::Terminal(TerminalError::NoEntity(e)) + if e.contains("refers to non-existent image") => + (), + _ => tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( + "{err_desc}: {enum_err}" + )))) + } - for page_num in to_replace { - tui.page_failed_display(page_num); + 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)?; } } - - execute!( - term.backend_mut(), - LeaveAlternateScreen, - crossterm::cursor::Show - )?; - disable_raw_mode()?; - - drop(maybe_logger); - - Ok(()) } fn on_notify_ev( diff --git a/src/renderer.rs b/src/renderer.rs index 708eb25..379ce70 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,17 +1,17 @@ -use std::{thread::sleep, time::Duration}; +use std::{collections::VecDeque, num::NonZeroUsize, thread::sleep, time::Duration}; use flume::{Receiver, SendError, Sender, TryRecvError}; -use itertools::Itertools; use mupdf::{ Colorspace, Document, Matrix, Page, Pixmap, Quad, TextPageOptions, text_page::SearchHitResponse }; use ratatui::layout::Rect; -use crate::PrerenderLimit; +use crate::{PrerenderLimit, skip::InterleavedAroundWithMax}; pub enum RenderNotif { Area(Rect), JumpToPage(usize), + PageNeedsReRender(usize), Search(String), Reload, Invert @@ -72,7 +72,7 @@ pub fn fill_default(vec: &mut Vec, 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>, @@ -94,6 +94,8 @@ pub fn start_rendering( let mut invert = false; let mut preserved_area = None; + let mut need_rerender = VecDeque::new(); + 'reload: loop { let doc = match Document::open(path) { Err(e) => { @@ -126,7 +128,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 @@ -135,7 +143,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 @@ -143,7 +151,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::(&mut rendered, n_pages); + fill_default::(&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 @@ -163,6 +171,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 { @@ -178,13 +189,18 @@ 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::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 @@ -214,28 +230,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 @@ -243,15 +252,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 @@ -260,18 +269,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; @@ -310,13 +310,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 @@ -371,7 +380,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; } @@ -544,3 +553,14 @@ fn count_search_results(page: &Page, search_term: &str) -> Result { + inner: &'a mut VecDeque +} + +impl<'a> Iterator for PopOnNext<'a> { + type Item = usize; + fn next(&mut self) -> Option { + self.inner.pop_front() + } +} diff --git a/src/skip.rs b/src/skip.rs index 5d0898d..9ac6c7a 100644 --- a/src/skip.rs +++ b/src/skip.rs @@ -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 { + 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::>(); + + 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 + ]); + } +} diff --git a/src/tui.rs b/src/tui.rs index 34fa2ec..2224cff 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -41,7 +41,7 @@ pub struct Tui { showing_help_msg: bool } -#[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 From 62c92141e39f958d5946193593e2fa4818819366 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 8 Jun 2025 21:13:39 -0600 Subject: [PATCH 09/22] Make it work correctly with ghostty image eviction too --- src/main.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 61db985..9e9fb37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -427,12 +427,10 @@ async fn enter_redraw_loop( if let Err((to_replace, err_desc, enum_err)) = maybe_err { match enum_err { - // This is the error that kitty provides us when it deletes an image due to - // memory constraints, so if we get it, we just fix it by re-rendering and - // don't display it to the user - TransmitError::Terminal(TerminalError::NoEntity(e)) - if e.contains("refers to non-existent image") => - (), + // 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 + TransmitError::Terminal(TerminalError::NoEntity(_)) => (), _ => tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!( "{err_desc}: {enum_err}" )))) From fc063efd4299df6bfccf5bbaa6e5fadb106e5bdc Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Mon, 9 Jun 2025 21:45:02 -0600 Subject: [PATCH 10/22] fall back to stdout if shms don't work --- benches/utils.rs | 4 +++- src/converter.rs | 45 ++++++++++++++++++++------------------------- src/kitty.rs | 32 +++++++++++++++++++++++++++++--- src/main.rs | 13 +++++++++++-- 4 files changed, 63 insertions(+), 31 deletions(-) diff --git a/benches/utils.rs b/benches/utils.rs index 7267bad..e3785bd 100644 --- a/benches/utils.rs +++ b/benches/utils.rs @@ -122,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(); diff --git a/src/converter.rs b/src/converter.rs index 1f281a6..b87c94d 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -52,7 +52,8 @@ pub async fn run_conversion_loop( sender: Sender>, receiver: Receiver, mut picker: Picker, - prerender: usize + prerender: usize, + shms_work: bool ) -> Result<(), SendError>> { let mut images = vec![]; let mut page: usize = 0; @@ -64,7 +65,8 @@ pub async fn run_conversion_loop( page: usize, iteration: &mut usize, prerender: usize, - pid: u32 + pid: u32, + shms_work: bool ) -> Result, RenderError> { if images.is_empty() || *iteration >= prerender { return Ok(None); @@ -117,10 +119,6 @@ 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 = match picker.protocol_type() { ProtocolType::Kitty => { let area = ratatui_image::protocol::ImageSource::round_pixel_size_to_cells( @@ -129,24 +127,20 @@ pub async fn run_conversion_loop( picker.font_size() ); - match kittage::image::Image::shm_from( - dyn_img, - &format!("__tdf_kittage_{pid}_page_{page_num}") - ) { - Ok(mut img) => { - img.num_or_id = - NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap()); - ConvertedImage::Kitty { - img: MaybeTransferred::NotYet(img), - area - } - } - // todo: fallback to non-shm image here without cloning dyn_img above - // Err(_) => ConvertedImage::Kitty(dyn_img.into()) - Err(e) => - return Err(RenderError::Converting(format!( - "Couldn't write to shm: {e}" - ))), + let mut img = if shms_work { + kittage::image::Image::shm_from( + dyn_img, + &format!("__tdf_kittage_{pid}_page_{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), + area } } _ => ConvertedImage::Generic( @@ -203,7 +197,8 @@ pub async fn run_conversion_loop( page, &mut iteration, prerender, - pid + pid, + shms_work ) { Ok(None) => break, Ok(Some(img)) => sender.send(Ok(img))?, diff --git a/src/kitty.rs b/src/kitty.rs index a5ce954..6be9672 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -1,8 +1,14 @@ -use std::io::Write; +use std::{io::Write, num::NonZeroU32}; -use crossterm::{cursor::MoveTo, event::EventStream, execute}; +use crossterm::{ + cursor::MoveTo, + event::EventStream, + execute, + terminal::{disable_raw_mode, enable_raw_mode} +}; +use image::DynamicImage; use kittage::{ - AsyncInputReader, ImageDimensions, ImageId, PixelFormat, + AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat, action::Action, delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, display::DisplayConfig, @@ -55,6 +61,26 @@ pub async fn run_action<'image, 'data, 'es>( .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_kittage_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>( images: Vec<(usize, &mut MaybeTransferred, Rect)>, ev_stream: &'es mut EventStream diff --git a/src/main.rs b/src/main.rs index 9e9fb37..95a6c81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ use ratatui_image::picker::{Picker, ProtocolType}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, - kitty::{display_kitty_images, run_action}, + kitty::{display_kitty_images, do_shms_work, run_action}, renderer::{self, RenderError, RenderInfo, RenderNotif}, tui::{BottomMessage, InputAction, MessageSetting, Tui} }; @@ -254,7 +254,12 @@ async fn main() -> Result<(), WrappedErr> { let (to_main, from_converter) = flume::unbounded(); let is_kitty = picker.protocol_type() == ProtocolType::Kitty; - tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20)); + + 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(), @@ -430,6 +435,10 @@ async fn enter_redraw_loop( // 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}" From a56fa8c817cdefd2efb6e401a22dc90b67983c04 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Thu, 12 Jun 2025 22:51:02 -0600 Subject: [PATCH 11/22] Make help page work again --- src/kitty.rs | 37 +++++++++++++++++++++++++++---------- src/main.rs | 46 ++++++++++++++++++++++------------------------ src/tui.rs | 11 ++++++----- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/kitty.rs b/src/kitty.rs index 6be9672..c805b67 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -20,6 +20,12 @@ use ratatui::prelude::Rect; use crate::converter::MaybeTransferred; +pub enum KittyDisplay<'tui> { + NoChange, + ClearImages, + DisplayImages(Vec<(usize, &'tui mut MaybeTransferred, Rect)>) +} + pub struct DbgWriter { w: W, #[cfg(debug_assertions)] @@ -82,7 +88,7 @@ pub async fn do_shms_work(ev_stream: &mut EventStream) -> bool { } pub async fn display_kitty_images<'es>( - images: Vec<(usize, &mut MaybeTransferred, Rect)>, + display: KittyDisplay<'_>, ev_stream: &'es mut EventStream ) -> Result< (), @@ -92,15 +98,26 @@ pub async fn display_kitty_images<'es>( TransmitError<<&'es mut EventStream as AsyncInputReader>::Error> ) > { - 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 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 (page_num, img, area) in images { diff --git a/src/main.rs b/src/main.rs index 95a6c81..80acbf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ use ratatui_image::picker::{Picker, ProtocolType}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, - kitty::{display_kitty_images, do_shms_work, run_action}, + kitty::{KittyDisplay, display_kitty_images, do_shms_work, run_action}, renderer::{self, RenderError, RenderInfo, RenderNotif}, tui::{BottomMessage, InputAction, MessageSetting, Tui} }; @@ -422,34 +422,32 @@ async fn enter_redraw_loop( } if needs_redraw { - let mut to_display = vec![]; + let mut to_display = KittyDisplay::NoChange; term.draw(|f| { to_display = tui.render(f, &main_area); })?; - if !to_display.is_empty() { - 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}" - )))) - } + 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))?; - } + 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))?; } } diff --git a/src/tui.rs b/src/tui.rs index 2224cff..d232863 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -24,6 +24,7 @@ use ratatui_image::Image; use crate::{ converter::{ConvertedImage, MaybeTransferred}, + kitty::KittyDisplay, renderer::{RenderError, fill_default}, skip::Skip }; @@ -133,10 +134,10 @@ impl Tui { &'s mut self, frame: &mut Frame<'_>, full_layout: &RenderLayout - ) -> Vec<(usize, &'s mut MaybeTransferred, Rect)> { + ) -> KittyDisplay<'s> { if self.showing_help_msg { self.render_help_msg(frame); - return vec![]; + return KittyDisplay::ClearImages; } if let Some((top_area, bottom_area)) = full_layout.top_and_bottom { @@ -243,7 +244,7 @@ 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); - vec![] + KittyDisplay::NoChange } else { // here we calculate how many pages can fit in the available area. let mut test_area_w = img_area.width; @@ -279,7 +280,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); - vec![] + KittyDisplay::ClearImages } else { execute!(stdout(), BeginSynchronizedUpdate).unwrap(); @@ -306,7 +307,7 @@ impl Tui { // then the whole diffing thing messes it up self.last_render.rect = size; - to_display + KittyDisplay::DisplayImages(to_display) } } } From a67ff7996ccc24d666294042f0b3ae1266432ad1 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 15 Jun 2025 16:11:15 -0600 Subject: [PATCH 12/22] zooming basically does what you'd expect now --- .gitignore | 1 + src/kitty.rs | 31 ++++- src/main.rs | 141 +++++++++++----------- src/tui.rs | 330 +++++++++++++++++++++++++++++++++++---------------- 4 files changed, 326 insertions(+), 177 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..0a42548 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +debug.log diff --git a/src/kitty.rs b/src/kitty.rs index c805b67..1fb7fbb 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -11,19 +11,26 @@ use kittage::{ AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat, action::Action, delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, - display::DisplayConfig, + display::{DisplayConfig, DisplayLocation}, error::TransmitError, image::Image, medium::Medium }; -use ratatui::prelude::Rect; +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<(usize, &'tui mut MaybeTransferred, Rect)>) + DisplayImages(Vec>) } pub struct DbgWriter { @@ -46,6 +53,7 @@ impl Write for DbgWriter { fn flush(&mut self) -> std::io::Result<()> { #[cfg(debug_assertions)] { + log::debug!("Writing to kitty: {:?}", self.buf); self.buf.clear(); } self.w.flush() @@ -120,10 +128,19 @@ pub async fn display_kitty_images<'es>( }; let mut err = None; - for (page_num, img, area) in images { - let config = DisplayConfig::default(); + for KittyReadyToDisplay { + img, + page_num, + pos, + display_loc + } in images + { + let config = DisplayConfig { + location: display_loc, + ..DisplayConfig::default() + }; - execute!(std::io::stdout(), MoveTo(area.x, area.y)).unwrap(); + execute!(std::io::stdout(), MoveTo(pos.x, pos.y)).unwrap(); let this_err = match img { MaybeTransferred::NotYet(image) => { @@ -155,6 +172,8 @@ pub async fn display_kitty_images<'es>( match res { Ok(img_id) => { + // TODO: Re-add this or at least make sure this sort of thing does happen + // fake_image.unlink_if_shm(); *img = MaybeTransferred::Transferred(img_id); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 80acbf7..0929481 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{stdout, BufReader, Read, Stdout, Write}, + io::{stdout, BufReader, Read, Stdout}, num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -25,7 +25,7 @@ use kittage::{ }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; -use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::{picker::{Picker, ProtocolType}, FontSize}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, @@ -152,68 +152,10 @@ 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::>(); - - // 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;;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::().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::().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, @@ -248,6 +190,8 @@ async fn main() -> Result<(), WrappedErr> { ) }); + let font_size = picker.font_size(); + let mut ev_stream = crossterm::event::EventStream::new(); let (to_converter, from_main) = flume::unbounded(); @@ -326,7 +270,8 @@ async fn main() -> Result<(), WrappedErr> { fullscreen, tui, &mut term, - main_area + main_area, + font_size ) .await .map_err(|e| { @@ -362,7 +307,8 @@ async fn enter_redraw_loop( mut fullscreen: bool, mut tui: Tui, term: &mut Terminal>, - mut main_area: tdf::tui::RenderLayout + mut main_area: tdf::tui::RenderLayout, + font_size: FontSize ) -> Result<(), Box> { loop { let mut needs_redraw = true; @@ -424,7 +370,7 @@ async fn enter_redraw_loop( if needs_redraw { let mut to_display = KittyDisplay::NoChange; term.draw(|f| { - to_display = tui.render(f, &main_area); + to_display = tui.render(f, &main_area, font_size); })?; let maybe_err = display_kitty_images(to_display, &mut ev_stream).await; @@ -499,3 +445,66 @@ fn parse_color_to_i32(cs: &str) -> Result 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> { + // 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::>(); + + // 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;;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::().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::().map_err(|_| { + WrappedErr( + format!( + "Your terminal said its width is {w}, but that is not a 16-bit unsigned integer" + ) + .into() + ) + })?; + + Ok((w, h)) +} diff --git a/src/tui.rs b/src/tui.rs index d232863..91d9424 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -8,23 +8,24 @@ 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} }; -use ratatui_image::Image; +use ratatui_image::{FontSize, Image}; use crate::{ converter::{ConvertedImage, MaybeTransferred}, - kitty::KittyDisplay, + kitty::{KittyDisplay, KittyReadyToDisplay}, renderer::{RenderError, fill_default}, skip::Skip }; @@ -39,7 +40,8 @@ pub struct Tui { prev_msg: Option, rendered: Vec, page_constraints: PageConstraints, - showing_help_msg: bool + showing_help_msg: bool, + zoom: Option } #[derive(Default)] @@ -71,6 +73,19 @@ struct PageConstraints { r_to_l: bool } +#[derive(Default)] +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: u16, + // 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)] @@ -100,7 +115,8 @@ impl Tui { last_render: LastRender::default(), rendered: vec![], page_constraints: PageConstraints { max_wide, r_to_l }, - showing_help_msg: false + showing_help_msg: false, + zoom: None } } @@ -133,106 +149,23 @@ impl Tui { pub fn render<'s>( &'s mut self, frame: &mut Frame<'_>, - full_layout: &RenderLayout + full_layout: &RenderLayout, + font_size: FontSize ) -> KittyDisplay<'s> { if self.showing_help_msg { self.render_help_msg(frame); 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::(); - 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; @@ -246,6 +179,60 @@ impl Tui { frame.render_widget(Skip::new(true), img_area); KittyDisplay::NoChange } else { + if let Some(ref 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 { .. })) + { + log::debug!("we're inside, it's kitty"); + let Some(ConvertedImage::Kitty { ref mut img, area }) = + self.rendered[self.page].img + else { + unreachable!() + }; + + let img_width = f32::from(area.width); + let img_height = f32::from(area.height); + let available_to_real_width_ratio = f32::from(img_area.width) / img_width; + let available_to_real_height_ratio = f32::from(img_area.height) / img_height; + + let (width, height) = + if available_to_real_width_ratio > available_to_real_height_ratio { + (img_width, img_height / available_to_real_width_ratio) + } else { + (img_width / available_to_real_height_ratio, img_height) + }; + + let width = (width * f32::from(font_size.0)) as u32; + let height = (height * f32::from(font_size.1)) as u32; + + 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 @@ -299,7 +286,12 @@ impl Tui { let maybe_img = Self::render_single_page(frame, img, Rect { width, ..img_area }); img_area.x += width; - maybe_img.map(|(img, r)| (idx + self.page, img, r)) + maybe_img.map(|(img, pos)| KittyReadyToDisplay { + img, + page_num: idx + self.page, + pos, + display_loc: DisplayLocation::default() + }) }) .collect::>(); @@ -316,17 +308,15 @@ impl Tui { frame: &mut Frame<'_>, page_img: &'img mut ConvertedImage, img_area: Rect - ) -> Option<(&'img mut MaybeTransferred, 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, area } => Some((img, Rect { + ConvertedImage::Kitty { img, area } => Some((img, Position { x: img_area.x, - y: img_area.y, - width: area.width, - height: area.height + y: img_area.y })) } } @@ -410,6 +400,100 @@ impl Tui { 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 + ) { + 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!("{} / {}", page_num + 1, 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(doc_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 !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::(); + 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 { fn jump_to_page( page: &mut usize, @@ -524,6 +608,42 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } + 'z' => { + self.zoom = match self.zoom { + None => Some(Zoom::default()), + Some(_) => None + }; + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'L' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'H' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'J' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'K' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } _ => None } } From 0578fccfa683ab3d176c7624cdd4203061f70aeb Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 15 Jun 2025 18:11:22 -0600 Subject: [PATCH 13/22] yay zooming woohoo --- src/converter.rs | 12 ++++++++-- src/kitty.rs | 2 -- src/lib.rs | 44 +++++++++++++++++++++++++++++++++++ src/main.rs | 10 +++++++- src/renderer.rs | 50 +++++++++++++++++++--------------------- src/tui.rs | 60 +++++++++++++++++++++++++++++++++--------------- 6 files changed, 128 insertions(+), 50 deletions(-) diff --git a/src/converter.rs b/src/converter.rs index b87c94d..d7301fd 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -1,4 +1,7 @@ -use std::num::{NonZeroU32, NonZeroUsize}; +use std::{ + num::{NonZeroU32, NonZeroUsize}, + time::{SystemTime, UNIX_EPOCH} +}; use flume::{Receiver, SendError, Sender, TryRecvError}; use futures_util::stream::StreamExt; @@ -127,10 +130,15 @@ pub async fn run_conversion_loop( picker.font_size() ); + let rn = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut img = if shms_work { kittage::image::Image::shm_from( dyn_img, - &format!("__tdf_kittage_{pid}_page_{page_num}") + &format!("__tdf_kittage_{pid}_page_{rn}_{page_num}") ) .map_err(|e| RenderError::Converting(format!("Couldn't write to shm: {e}")))? } else { diff --git a/src/kitty.rs b/src/kitty.rs index 1fb7fbb..447e2ab 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -172,8 +172,6 @@ pub async fn display_kitty_images<'es>( match res { Ok(img_id) => { - // TODO: Re-add this or at least make sure this sort of thing does happen - // fake_image.unlink_if_shm(); *img = MaybeTransferred::Transferred(img_id); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index a93e08d..08c7de4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use std::num::NonZeroUsize; #[global_allocator] static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; +#[derive(PartialEq)] pub enum PrerenderLimit { All, Limited(NonZeroUsize) @@ -13,3 +14,46 @@ 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 + } +} diff --git a/src/main.rs b/src/main.rs index 0929481..30eb533 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,6 +331,9 @@ async fn enter_redraw_loop( 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(); + } } } }, @@ -354,7 +357,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), } }, diff --git a/src/renderer.rs b/src/renderer.rs index 379ce70..a1e946a 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -6,13 +6,17 @@ use mupdf::{ }; use ratatui::layout::Rect; -use crate::{PrerenderLimit, skip::InterleavedAroundWithMax}; +use crate::{ + FitOrFill, PrerenderLimit, ScaledResult, scale_img_for_area, skip::InterleavedAroundWithMax +}; +#[derive(Debug)] pub enum RenderNotif { Area(Rect), JumpToPage(usize), PageNeedsReRender(usize), Search(String), + SwitchFitOrFill(FitOrFill), Reload, Invert } @@ -93,6 +97,7 @@ pub fn start_rendering( 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(); @@ -192,6 +197,12 @@ pub fn start_rendering( 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; @@ -287,6 +298,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 @@ -406,7 +418,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; } @@ -430,6 +442,7 @@ struct RenderedContext { result_rects: Vec } +#[expect(clippy::too_many_arguments)] fn render_single_page_to_ctx( page: &Page, search_term: Option<&str>, @@ -437,6 +450,7 @@ fn render_single_page_to_ctx( invert: bool, black: i32, white: i32, + fit_or_fill: FitOrFill, (area_w, area_h): (f32, f32) ) -> Result { let result_rects = match prev_render.num_search_found { @@ -447,30 +461,14 @@ 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); - - // and get its aspect ratio - let p_aspect_ratio = p_width / p_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_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; + let page_dim = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0); + + let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill); + let ScaledResult { + width: surface_w, + height: surface_h, + scale_factor + } = scaled; let colorspace = Colorspace::device_rgb(); let matrix = Matrix::new_scale(scale_factor, scale_factor); diff --git a/src/tui.rs b/src/tui.rs index 91d9424..8bef541 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -24,6 +24,7 @@ use ratatui::{ use ratatui_image::{FontSize, Image}; use crate::{ + FitOrFill, converter::{ConvertedImage, MaybeTransferred}, kitty::{KittyDisplay, KittyReadyToDisplay}, renderer::{RenderError, fill_default}, @@ -32,7 +33,7 @@ use crate::{ 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 @@ -73,7 +74,7 @@ struct PageConstraints { r_to_l: bool } -#[derive(Default)] +#[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) @@ -89,7 +90,7 @@ struct Zoom { // 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, // The number of results for the current search term that have been found on this page. None if @@ -179,7 +180,7 @@ impl Tui { frame.render_widget(Skip::new(true), img_area); KittyDisplay::NoChange } else { - if let Some(ref zoom) = self.zoom { + 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. @@ -191,27 +192,46 @@ impl Tui { .as_ref() .is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. })) { - log::debug!("we're inside, it's kitty"); let Some(ConvertedImage::Kitty { ref mut img, area }) = self.rendered[self.page].img else { unreachable!() }; - let img_width = f32::from(area.width); - let img_height = f32::from(area.height); - let available_to_real_width_ratio = f32::from(img_area.width) / img_width; - let available_to_real_height_ratio = f32::from(img_area.height) / img_height; + // Ugh I don't like this logic. I wish we could simplify it. + let (cell_width, cell_height) = if area.width >= img_area.width + && area.height >= img_area.height + { + (f32::from(img_area.width), f32::from(img_area.height)) + } else { + let img_width = f32::from(area.width); + let img_height = f32::from(area.height); + let available_to_real_width_ratio = f32::from(img_area.width) / img_width; + let available_to_real_height_ratio = + f32::from(img_area.height) / img_height; - let (width, height) = if available_to_real_width_ratio > available_to_real_height_ratio { (img_width, img_height / available_to_real_width_ratio) } else { (img_width / available_to_real_height_ratio, img_height) - }; + } + }; - let width = (width * f32::from(font_size.0)) as u32; - let height = (height * f32::from(font_size.1)) as u32; + let width = (cell_width * f32::from(font_size.0)) as u32; + let height = (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(area.width.saturating_sub(cell_width as u16)); + zoom.cell_pan_from_top = zoom + .cell_pan_from_top + .min(area.height.saturating_sub(cell_height as u16)); return KittyDisplay::DisplayImages(vec![KittyReadyToDisplay { img, @@ -314,7 +334,7 @@ impl Tui { frame.render_widget(Image::new(page_img), img_area); None } - ConvertedImage::Kitty { img, area } => Some((img, Position { + ConvertedImage::Kitty { img, area: _ } => Some((img, Position { x: img_area.x, y: img_area.y })) @@ -609,12 +629,13 @@ impl Tui { Some(InputAction::Redraw) } 'z' => { - self.zoom = match self.zoom { - None => Some(Zoom::default()), - Some(_) => None + 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::Redraw) + Some(InputAction::SwitchRenderZoom(f_or_f)) } 'L' => { if let Some(z) = &mut self.zoom { @@ -853,7 +874,8 @@ pub enum InputAction { Search(String), QuitApp, Invert, - Fullscreen + Fullscreen, + SwitchRenderZoom(crate::FitOrFill) } #[derive(Copy, Clone)] From 02b447a98e3cd9a1cd4c14eed3a6c67c5ad98eb4 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Tue, 17 Jun 2025 09:25:52 -0600 Subject: [PATCH 14/22] clean up top and bottom rendering --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/tui.rs | 29 +++++++++++------------------ 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd0f51e..5abd66c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,9 +997,9 @@ dependencies = [ [[package]] name = "flexi_logger" -version = "0.30.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb03342077df16d5b1400d7bed00156882846d7a479ff61a6f10594bcc3423d8" +checksum = "ab9765cc4ba26211f932a7a37649ec88752f7abcbd8822617572562ce31234df" dependencies = [ "chrono", "log", diff --git a/Cargo.toml b/Cargo.toml index 7b24149..eccc2a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ memmap2 = "*" # logging log = "0.4.27" -flexi_logger = "0.30.2" +flexi_logger = "0.31" # for tracing with tokio-console console-subscriber = { version = "0.4.0", optional = true } diff --git a/src/tui.rs b/src/tui.rs index 8bef541..692e108 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -428,31 +428,24 @@ impl Tui { 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 { - right: 2, - left: 2, + bottom: 1, ..Padding::default() }) .borders(Borders::BOTTOM); - let top_area = top_block.inner(top_area); - - let page_nums_text = format!("{} / {}", page_num + 1, 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(doc_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 { From da8cdd1fbdbdcbecbc3c722d1b93cd3bf51de46b Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Wed, 18 Jun 2025 10:48:00 -0600 Subject: [PATCH 15/22] Only allow zooming in kitty --- src/main.rs | 7 ++++++- src/tui.rs | 14 ++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 30eb533..9ecd501 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,7 +209,12 @@ async fn main() -> Result<(), WrappedErr> { || "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 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| { diff --git a/src/tui.rs b/src/tui.rs index 692e108..3102def 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -42,6 +42,7 @@ pub struct Tui { rendered: Vec, page_constraints: PageConstraints, showing_help_msg: bool, + is_kitty: bool, zoom: Option } @@ -107,7 +108,7 @@ pub struct RenderLayout { } impl Tui { - pub fn new(name: String, max_wide: Option, r_to_l: bool) -> Tui { + pub fn new(name: String, max_wide: Option, r_to_l: bool, is_kitty: bool) -> Tui { Self { name, page: 0, @@ -117,6 +118,7 @@ impl Tui { rendered: vec![], page_constraints: PageConstraints { max_wide, r_to_l }, showing_help_msg: false, + is_kitty, zoom: None } } @@ -621,7 +623,7 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } - 'z' => { + 'z' if self.is_kitty => { let (zoom, f_or_f) = match self.zoom { None => (Some(Zoom::default()), FitOrFill::Fill), Some(_) => (None, FitOrFill::Fit) @@ -630,28 +632,28 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } - 'L' => { + 'L' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1); } self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } - 'H' => { + 'H' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1); } self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } - 'J' => { + 'J' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1); } self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } - 'K' => { + 'K' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1); } From 484d248e26095667726dfa58d6bc2e6990253e0e Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Wed, 18 Jun 2025 12:13:59 -0600 Subject: [PATCH 16/22] Add debug logging and fix cursor placement after image display --- Cargo.lock | 5 +++-- Cargo.toml | 4 ++-- src/converter.rs | 1 + src/kitty.rs | 8 +++++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5abd66c..a94e954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,9 +997,9 @@ dependencies = [ [[package]] name = "flexi_logger" -version = "0.31.0" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9765cc4ba26211f932a7a37649ec88752f7abcbd8822617572562ce31234df" +checksum = "759bfa52db036a2db54f0b5f0ff164efa249b3014720459c5ea4198380c529bc" dependencies = [ "chrono", "log", @@ -1591,6 +1591,7 @@ dependencies = [ "crossterm", "futures-core", "image", + "log", "memchr", "memmap2", "psx-shm", diff --git a/Cargo.toml b/Cargo.toml index eccc2a9..6c236b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,8 @@ mimalloc = "0.1.43" nix = { version = "0.30.0", features = ["signal"] } mupdf = { version = "0.5.0", default-features = false, features = ["svg", "system-fonts", "img"] } rayon = { version = "*", default-features = false } -# kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate"] } -kittage = { git = "https://github.com/itsjunetime/kittage.git", features = ["crossterm-tokio", "image-crate"] } +# 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 diff --git a/src/converter.rs b/src/converter.rs index d7301fd..48ed919 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -20,6 +20,7 @@ use crate::{ skip::InterleavedAroundWithMax }; +#[derive(Debug)] pub enum MaybeTransferred { NotYet(kittage::image::Image<'static>), Transferred(kittage::ImageId) diff --git a/src/kitty.rs b/src/kitty.rs index 447e2ab..7cbde8a 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -11,7 +11,7 @@ use kittage::{ AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat, action::Action, delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, - display::{DisplayConfig, DisplayLocation}, + display::{CursorMovementPolicy, DisplayConfig, DisplayLocation}, error::TransmitError, image::Image, medium::Medium @@ -137,11 +137,15 @@ pub async fn display_kitty_images<'es>( { 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 { @@ -191,6 +195,8 @@ pub async fn display_kitty_images<'es>( .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); From b368f8d41d2a39fb8f0bff063a7f03b5c1094ee6 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Wed, 18 Jun 2025 13:19:17 -0600 Subject: [PATCH 17/22] yaaaay zooming out once you're already zoomed in and respecting kitty's limits for how big of an image to display --- src/converter.rs | 28 +++++++++------- src/renderer.rs | 17 ++++++++-- src/tui.rs | 83 +++++++++++++++++++++++++++++++++--------------- 3 files changed, 88 insertions(+), 40 deletions(-) diff --git a/src/converter.rs b/src/converter.rs index 48ed919..2b6bc09 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -28,14 +28,25 @@ pub enum MaybeTransferred { pub enum ConvertedImage { Generic(Protocol), - Kitty { img: MaybeTransferred, area: Rect } + Kitty { + img: MaybeTransferred, + cell_w: u16, + cell_h: u16 + } } impl ConvertedImage { - pub fn area(&self) -> Rect { + pub fn w_h(&self) -> (u16, u16) { match self { - Self::Generic(prot) => prot.area(), - Self::Kitty { img: _, area } => *area + Self::Generic(prot) => { + let a = prot.area(); + (a.width, a.height) + } + Self::Kitty { + img: _, + cell_w, + cell_h + } => (*cell_w, *cell_h) } } } @@ -125,12 +136,6 @@ pub async fn run_conversion_loop( let txt_img = match picker.protocol_type() { ProtocolType::Kitty => { - let area = ratatui_image::protocol::ImageSource::round_pixel_size_to_cells( - dyn_img.width(), - dyn_img.height(), - picker.font_size() - ); - let rn = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -149,7 +154,8 @@ pub async fn run_conversion_loop( img.num_or_id = NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap()); ConvertedImage::Kitty { img: MaybeTransferred::NotYet(img), - area + cell_w: page_info.img_data.cell_w, + cell_h: page_info.img_data.cell_h } } _ => ConvertedImage::Generic( diff --git a/src/renderer.rs b/src/renderer.rs index a1e946a..f3a0fb1 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -10,6 +10,8 @@ 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), @@ -313,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; @@ -465,11 +469,18 @@ fn render_single_page_to_ctx( let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill); let ScaledResult { - width: surface_w, - height: surface_h, - scale_factor + width: mut surface_w, + height: mut surface_h, + mut scale_factor } = scaled; + 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); diff --git a/src/tui.rs b/src/tui.rs index 3102def..2480da7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -79,7 +79,7 @@ struct PageConstraints { 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: u16, + 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, @@ -194,33 +194,46 @@ impl Tui { .as_ref() .is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. })) { - let Some(ConvertedImage::Kitty { ref mut img, area }) = - self.rendered[self.page].img + let Some(ConvertedImage::Kitty { + ref mut img, + cell_w, + cell_h + }) = self.rendered[self.page].img else { unreachable!() }; + 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 + } + } + // Ugh I don't like this logic. I wish we could simplify it. - let (cell_width, cell_height) = if area.width >= img_area.width - && area.height >= img_area.height - { - (f32::from(img_area.width), f32::from(img_area.height)) - } else { - let img_width = f32::from(area.width); - let img_height = f32::from(area.height); - let available_to_real_width_ratio = f32::from(img_area.width) / img_width; - let available_to_real_height_ratio = - f32::from(img_area.height) / img_height; - - if available_to_real_width_ratio > available_to_real_height_ratio { - (img_width, img_height / available_to_real_width_ratio) + let (new_cell_width, new_cell_height) = + if cell_w >= img_area.width && cell_h >= img_area.height { + (f32::from(img_area.width), f32::from(img_area.height)) } else { - (img_width / available_to_real_height_ratio, img_height) - } - }; + 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; + + 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) + } + }; - let width = (cell_width * f32::from(font_size.0)) as u32; - let height = (cell_height * f32::from(font_size.1)) as u32; + 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, @@ -230,10 +243,10 @@ impl Tui { zoom.cell_pan_from_left = zoom .cell_pan_from_left - .min(area.width.saturating_sub(cell_width as u16)); + .min(cell_w.saturating_sub(new_cell_width as u16)); zoom.cell_pan_from_top = zoom .cell_pan_from_top - .min(area.height.saturating_sub(cell_height as u16)); + .min(cell_h.saturating_sub(new_cell_height as u16)); return KittyDisplay::DisplayImages(vec![KittyReadyToDisplay { img, @@ -271,7 +284,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.area().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) => { @@ -336,7 +349,11 @@ impl Tui { frame.render_widget(Image::new(page_img), img_area); None } - ConvertedImage::Kitty { img, area: _ } => Some((img, Position { + ConvertedImage::Kitty { + img, + cell_h: _, + cell_w: _ + } => Some((img, Position { x: img_area.x, y: img_area.y })) @@ -396,7 +413,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.area().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 { @@ -632,6 +649,20 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } + 'o' if self.is_kitty => { + if let Some(z) = &mut self.zoom { + z.level = z.level.saturating_add(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'O' if self.is_kitty => { + if let Some(z) = &mut self.zoom { + z.level = z.level.saturating_sub(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } 'L' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1); From 05bfee148c6329b27a4188f48f073f3b9fe38303 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Wed, 18 Jun 2025 16:34:38 -0600 Subject: [PATCH 18/22] mmmm maybe it's finally ready to merge... --- src/tui.rs | 97 +++++++++++++++++++++++++----------------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 2480da7..9804999 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -203,31 +203,35 @@ impl Tui { 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), + 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 cell_w >= img_area.width && cell_h >= img_area.height { - (f32::from(img_area.width), f32::from(img_area.height)) + if available_to_real_width_ratio > available_to_real_height_ratio { + (img_width, img_area_height / available_to_real_width_ratio) } else { - 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; - - 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) - } + (img_area_width / available_to_real_height_ratio, img_height) }; log::debug!("new_cell stuff is {new_cell_width}x{new_cell_height}"); @@ -649,48 +653,27 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } - 'o' if self.is_kitty => { + /*'o' if self.is_kitty => { if let Some(z) = &mut self.zoom { z.level = z.level.saturating_add(1); } self.last_render.rect = Rect::default(); Some(InputAction::Redraw) - } - 'O' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.level = z.level.saturating_sub(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - } - 'L' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - } - 'H' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - } - 'J' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - } - 'K' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - } + }*/ + '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 } } @@ -790,6 +773,16 @@ impl Tui { } } + // 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 { + 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}"), From 16ac61dc8e51f7abda8d9c1786aa11b9371d7fce Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Fri, 1 Aug 2025 20:48:21 -0600 Subject: [PATCH 19/22] Update deps --- Cargo.lock | 19 +++++-------------- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a94e954..2d1c95e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,9 +621,9 @@ dependencies = [ [[package]] name = "criterion" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" dependencies = [ "anes", "cast", @@ -645,12 +645,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1510,15 +1510,6 @@ dependencies = [ "libc", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 6c236b8..a779f33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,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]] From 1c797d4653a84006cfacc37ce0207bcb45105a61 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 3 Aug 2025 09:52:46 -0600 Subject: [PATCH 20/22] Switch around list of items on changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7895b..385971a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Unreleased -- Update ratatui(-image) dependencies +- 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 From 196f7fb589f288e8ec3d8e5dbed93f81d07ba8aa Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sun, 3 Aug 2025 09:55:43 -0600 Subject: [PATCH 21/22] fmt --- src/main.rs | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9ecd501..4527a8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{stdout, BufReader, Read, Stdout}, + io::{BufReader, Read, Stdout, stdout}, num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -15,8 +15,8 @@ use crossterm::{ enable_raw_mode, window_size } }; -use flume::{Sender, r#async::RecvStream}; use flexi_logger::FileSpec; +use flume::{Sender, r#async::RecvStream}; use futures_util::{FutureExt, stream::StreamExt}; use kittage::{ action::Action, @@ -25,7 +25,10 @@ use kittage::{ }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; -use ratatui_image::{picker::{Picker, ProtocolType}, FontSize}; +use ratatui_image::{ + FontSize, + picker::{Picker, ProtocolType} +}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, @@ -101,16 +104,15 @@ async fn main() -> Result<(), WrappedErr> { 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()))? - ); + 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(); @@ -250,7 +252,9 @@ async fn main() -> Result<(), WrappedErr> { &mut ev_stream ) .await - .map_err(|e| WrappedErr(format!("Couldn't delete all previous images from memory: {e}").into()))?; + .map_err(|e| { + WrappedErr(format!("Couldn't delete all previous images from memory: {e}").into()) + })?; } let fullscreen = flags.fullscreen.unwrap_or_default(); From dcc3dbc9588da88bc548332f9c4ecc626278f23f Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Wed, 6 Aug 2025 09:28:29 -0600 Subject: [PATCH 22/22] Small fixes to avoid panic and allow zooming back in after zooming out --- src/tui.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 9804999..c4873ec 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -394,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)) } @@ -653,13 +654,9 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } - /*'o' if self.is_kitty => { - if let Some(z) = &mut self.zoom { - z.level = z.level.saturating_add(1); - } - self.last_render.rect = Rect::default(); - Some(InputAction::Redraw) - }*/ + '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| {