From b9ca0ae0bfbaf075df4f4684ce04745dfd96a51d Mon Sep 17 00:00:00 2001 From: Max Dexheimer Date: Sat, 29 Nov 2025 20:09:46 +0100 Subject: [PATCH] Rewrite zooming --- src/tui.rs | 415 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 241 insertions(+), 174 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index fa8578c..fce51da 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -78,8 +78,8 @@ struct PageConstraints { #[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) + // just how much 'zoom' you have. 0 means it fills the screen (instead of fits), such + // that one axis is fully on-screen level: i16, // how many terminal-cells worth of content overflow the left side of the screen (and are thus // not displayed) @@ -88,6 +88,21 @@ struct Zoom { // not displayed) cell_pan_from_top: u16 } +impl Zoom { + /// Returns the zoom factor, where 1 is the default and means fill-screen + fn factor(&self) -> f32 { + // TODO: Make these configurable once we have a good way to set options after startup + const ZOOM_RATE: f32 = 1.1; + const ZOOM_RATE_GRANULAR: f32 = 1.05; + + if self.level > 0 { + ZOOM_RATE.powi(self.level.into()) + } else { + // use a more granular zoom rate for the steps between fit-screen and fill-screen + ZOOM_RATE_GRANULAR.powi(self.level.into()) + } + } +} // 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 @@ -150,6 +165,129 @@ impl Tui { } } + fn render_zoomed<'s>( + // area of the 'fit-screen' page + mut img_area: Rect, + font_size: FontSize, + zoom: &mut Zoom, + img: &'s mut MaybeTransferred, + page_num: usize, + img_cell_w: u16, + img_cell_h: u16 + ) -> KittyDisplay<'s> { + log::debug!("zoom is {zoom:#?}"); + log::debug!("page area is {img_area:#?}"); + log::debug!("img dimensions are {img_cell_w}x{img_cell_h}"); + + // Dimensions of the section of the image to be displayed. + // Kittage calls this the "image area to display". + // We need to shrink this or the page area in order to zoom in or out, + // respectively. + let mut img_section_w = f32::from(img_cell_w); + let mut img_section_h = f32::from(img_cell_h); + + let zoom_factor = zoom.factor(); + + if zoom_factor >= 1.0 { + // Use a smaller section of the image. This efficively zooms into that section. + img_section_w /= zoom_factor; + img_section_h /= zoom_factor; + } else { + // Shrink the page area, such that the fill-screen conversion + // will zoom out of the image. + let initial_page_w = f32::from(img_area.width); + let initial_page_h = f32::from(img_area.height); + + // how many pages the image is wide/high + let img_page_w_ratio = img_section_w / initial_page_w; + let img_page_h_ratio = img_section_h / initial_page_h; + + let shrink_move_page = |dim: &mut u16, pos: &mut u16, axis_zoom_factor: f32| { + let old_dim = *dim; + // The axis zoom factor tells us what portion of the axis + // we need to show. + *dim = (f32::from(*dim) * axis_zoom_factor) as u16; + + *pos += old_dim + .checked_sub(*dim) + .expect("zooming out should shrink the image") + / 2; + }; + + // TODO: Detect max zoom-out in zoom levels + if img_page_w_ratio < img_page_h_ratio { + // vertical scroll / tall image. zooming out means decreasing the width of the page area + shrink_move_page( + &mut img_area.width, + &mut img_area.x, + // disallow zooming out past fit-screen + zoom_factor.max(1.0 / img_page_h_ratio) + ); + } else { + // horizontal scroll / wide image. zooming out means decreasing the width of the page area + shrink_move_page( + &mut img_area.height, + &mut img_area.y, + // disallow zooming out past fit-screen + zoom_factor.max(1.0 / img_page_w_ratio) + ); + } + } + log::debug!("after adjustment, page area is {img_area:#?}"); + + // Crop the image such that in the end, the aspect ratio of the section + // is the same as that of the page area. This effectively performs the + // conversion to fill-screen. + // Note that this only works because cell_w, cell_h is in fit-screen + // format, i.e. the cell size and the page area already share at + // least one dimension. + { + let page_area_w = f32::from(img_area.width); + let page_area_h = f32::from(img_area.height); + + // how many pages the image is wide/high + // Note that this is not the same as during the + // zoom-out calculation, since it changed the page + // dimensions. + let img_page_w_ratio = img_section_w / page_area_w; + let img_page_h_ratio = img_section_h / page_area_h; + + if img_page_w_ratio < img_page_h_ratio { + img_section_h = page_area_h * img_page_w_ratio; + } else { + img_section_w = page_area_w * img_page_h_ratio; + } + } + + let width = (img_section_w * f32::from(font_size.0)) as u32; + let height = (img_section_h * f32::from(font_size.1)) as u32; + + zoom.cell_pan_from_left = zoom + .cell_pan_from_left + .min(img_cell_w.saturating_sub(img_section_w.ceil() as u16)); + zoom.cell_pan_from_top = zoom + .cell_pan_from_top + .min(img_cell_h.saturating_sub(img_section_h.ceil() as u16)); + + KittyDisplay::DisplayImages(vec![KittyReadyToDisplay { + img, + page_num, + 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() + } + }]) + } + // TODO: Make a way to fill the width of the screen with one page and scroll down to view it #[must_use] pub fn render<'s>( @@ -183,190 +321,120 @@ impl Tui { // be written and set to skip it so that ratatui doesn't spend a lot of time diffing it // each re-render frame.render_widget(Skip::new(true), img_area); - KittyDisplay::NoChange - } else { - if let Some(ref mut zoom) = self.zoom { - // yes this is ugly and I hate it. it's due to the limitations that currently exist - // in the borrow checker. Once `-Zpolonius=next` is stabilized, we can rework this - // to look like what we expect. - // See https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md#problem-case-3-conditional-control-flow-across-functions - // You can also rewrite this to just if an `if let` and run it under - // `RUSTFLAGS="-Zpolonius=next"` and see that it works - if self.rendered[self.page] - .img - .as_ref() - .is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. })) - { - let Some(ConvertedImage::Kitty { - ref mut img, - cell_w, - cell_h - }) = self.rendered[self.page].img - else { - unreachable!() - }; - - log::debug!("zoom is now {zoom:#?}"); - log::debug!("img_area is {img_area:#?}"); - log::debug!("img dimensions are {cell_w}x{cell_h}"); - - let img_width = f32::from(cell_w); - let img_height = f32::from(cell_h); - - let img_aspect_ratio = img_width / img_height; - - if zoom.level < 0 { - let old_width = img_area.width; - img_area.width = img_area - .width - .saturating_sub((zoom.level * 2).unsigned_abs()) - .max( - old_width - .min((f32::from(img_area.height) * img_aspect_ratio) as u16) - ); - - img_area.x += old_width - .checked_sub(img_area.width) - .expect("Zooming out shrinks the image") - / 2; - - log::debug!("after adjustment, img_area is {img_area:#?}"); - - // TODO: Find a way to detect when we've hit the maximum zoom-out and stop - // more zooming out - } - - // Ugh I don't like this logic. I wish we could simplify it. - let img_area_width = f32::from(img_area.width); - let img_area_height = f32::from(img_area.height); - let available_to_real_width_ratio = img_area_width / img_width; - let available_to_real_height_ratio = img_area_height / img_height; - - let (new_cell_width, new_cell_height) = - if available_to_real_width_ratio > available_to_real_height_ratio { - (img_width, img_area_height / available_to_real_width_ratio) - } else { - (img_area_width / available_to_real_height_ratio, img_height) - }; - - log::debug!("new_cell stuff is {new_cell_width}x{new_cell_height}"); - - let width = (new_cell_width * f32::from(font_size.0)) as u32; - let height = (new_cell_height * f32::from(font_size.1)) as u32; - - self.last_render = LastRender { - rect: size, - pages_shown: 1, - unused_width: 0 - }; - - zoom.cell_pan_from_left = zoom - .cell_pan_from_left - .min(cell_w.saturating_sub(new_cell_width as u16)); - zoom.cell_pan_from_top = zoom - .cell_pan_from_top - .min(cell_h.saturating_sub(new_cell_height as u16)); + return KittyDisplay::NoChange; + } - 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() - } - }]); - } + if let Some(ref mut zoom) = self.zoom { + // yes this is ugly and I hate it. it's due to the limitations that currently exist + // in the borrow checker. Once `-Zpolonius=next` is stabilized, we can rework this + // to look like what we expect. + // See https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md#problem-case-3-conditional-control-flow-across-functions + // You can also rewrite this to just if an `if let` and run it under + // `RUSTFLAGS="-Zpolonius=next"` and see that it works + if self.rendered[self.page] + .img + .as_ref() + .is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. })) + { + let Some(ConvertedImage::Kitty { + ref mut img, + cell_w, + cell_h + }) = self.rendered[self.page].img + else { + unreachable!() + }; + + self.last_render = LastRender { + rect: size, + pages_shown: 1, + unused_width: 0 + }; + return Self::render_zoomed( + img_area, font_size, zoom, img, self.page, cell_w, cell_h + ); } + } - // 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 - let mut page_sizes = self.rendered[self.page..] - .iter_mut() - // and get this to represent a count of how many we're looking at so far to render - .enumerate() - // and only take as many as are ready to be rendered - .take_while(|(idx, page)| { - let mut take = page.img.is_some(); - if let Some(max) = self.page_constraints.max_wide { - take &= *idx < max.get(); - } - take - }) - // and map it to their width (in cells on the terminal, not pixels) - .filter_map(|(_, page)| { - page.img.as_mut().map(|img| { - let (w, h) = img.w_h(); - (w, h, 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) => { - test_area_w = new_val; - true - } - None => false + // 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 + let mut page_sizes = self.rendered[self.page..] + .iter_mut() + // and get this to represent a count of how many we're looking at so far to render + .enumerate() + // and only take as many as are ready to be rendered + .take_while(|(idx, page)| { + let mut take = page.img.is_some(); + if let Some(max) = self.page_constraints.max_wide { + take &= *idx < max.get(); + } + take + }) + // and map it to their width (in cells on the terminal, not pixels) + .filter_map(|(_, page)| { + page.img.as_mut().map(|img| { + let (w, h) = img.w_h(); + (w, h, img) }) - .collect::>(); + }) + // 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) => { + test_area_w = new_val; + true + } + None => false + }) + .collect::>(); - if self.page_constraints.r_to_l { - page_sizes.reverse(); - } + if self.page_constraints.r_to_l { + page_sizes.reverse(); + } - if page_sizes.is_empty() { - // If none are ready to render, just show the loading thing - Self::render_loading_in(frame, img_area); - KittyDisplay::ClearImages - } else { - execute!(stdout(), BeginSynchronizedUpdate).unwrap(); + if page_sizes.is_empty() { + // If none are ready to render, just show the loading thing + Self::render_loading_in(frame, img_area); + KittyDisplay::ClearImages + } else { + execute!(stdout(), BeginSynchronizedUpdate).unwrap(); - let total_width = page_sizes.iter().map(|(w, _, _)| w).sum::(); + let total_width = page_sizes.iter().map(|(w, _, _)| w).sum::(); - self.last_render.pages_shown = page_sizes.len(); + self.last_render.pages_shown = page_sizes.len(); - let unused_width = img_area.width - total_width; - self.last_render.unused_width = unused_width; - img_area.x += unused_width / 2; + let unused_width = img_area.width - total_width; + self.last_render.unused_width = unused_width; + img_area.x += unused_width / 2; - if let Some(total_height) = page_sizes.iter().map(|(_, h, _)| h).max() { - // This subtraction might sporadicly fail while shrinking the window. - if let Some(unused_height) = img_area.height.checked_sub(*total_height) { - img_area.y += unused_height / 2; - } + if let Some(total_height) = page_sizes.iter().map(|(_, h, _)| h).max() { + // This subtraction might sporadicly fail while shrinking the window. + if let Some(unused_height) = img_area.height.checked_sub(*total_height) { + img_area.y += unused_height / 2; } + } - let to_display = page_sizes - .into_iter() - .enumerate() - .filter_map(|(idx, (width, _, img))| { - let maybe_img = - Self::render_single_page(frame, img, Rect { width, ..img_area }); - img_area.x += width; - maybe_img.map(|(img, pos)| KittyReadyToDisplay { - img, - page_num: idx + self.page, - pos, - display_loc: DisplayLocation::default() - }) + let to_display = page_sizes + .into_iter() + .enumerate() + .filter_map(|(idx, (width, _, img))| { + let maybe_img = + Self::render_single_page(frame, img, Rect { width, ..img_area }); + img_area.x += width; + maybe_img.map(|(img, pos)| KittyReadyToDisplay { + img, + page_num: idx + self.page, + pos, + display_loc: DisplayLocation::default() }) - .collect::>(); + }) + .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; + // we want to set this at the very end so it doesn't get set somewhere halfway through and + // then the whole diffing thing messes it up + self.last_render.rect = size; - KittyDisplay::DisplayImages(to_display) - } + KittyDisplay::DisplayImages(to_display) } } @@ -694,9 +762,8 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } - 'o' if self.is_kitty => self.update_zoom(|z| - // TODO: for now, we don't let people zoom in past fill-screen - z.level = z.level.saturating_add(1).min(0)), + 'o' if self.is_kitty => + self.update_zoom(|z| z.level = z.level.saturating_add(1)), '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| {