Rewrite zooming (#121)

This commit is contained in:
Max
2025-12-03 02:59:38 +01:00
committed by GitHub
parent e5cf92221f
commit 9b9796e718
+242 -175
View File
@@ -78,8 +78,8 @@ struct PageConstraints {
#[derive(Default, Debug)] #[derive(Default, Debug)]
struct Zoom { struct Zoom {
// just how much 'zoom' you have. Doesn't relate to anything specific yet, except that 0 means // just how much 'zoom' you have. 0 means it fills the screen (instead of fits), such
// it fills the screen (instead of fits) // that one axis is fully on-screen
level: i16, level: i16,
// how many terminal-cells worth of content overflow the left side of the screen (and are thus // how many terminal-cells worth of content overflow the left side of the screen (and are thus
// not displayed) // not displayed)
@@ -88,6 +88,21 @@ struct Zoom {
// not displayed) // not displayed)
cell_pan_from_top: u16 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 // 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 // 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 // TODO: Make a way to fill the width of the screen with one page and scroll down to view it
#[must_use] #[must_use]
pub fn render<'s>( 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 // be written and set to skip it so that ratatui doesn't spend a lot of time diffing it
// each re-render // each re-render
frame.render_widget(Skip::new(true), img_area); frame.render_widget(Skip::new(true), img_area);
KittyDisplay::NoChange return KittyDisplay::NoChange;
}
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
})
.collect::<Vec<_>>();
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 { } else {
if let Some(ref mut zoom) = self.zoom { execute!(stdout(), BeginSynchronizedUpdate).unwrap();
// 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:#?}"); let total_width = page_sizes.iter().map(|(w, _, _)| w).sum::<u16>();
log::debug!("img_area is {img_area:#?}");
log::debug!("img dimensions are {cell_w}x{cell_h}");
let img_width = f32::from(cell_w); self.last_render.pages_shown = page_sizes.len();
let img_height = f32::from(cell_h);
let img_aspect_ratio = img_width / img_height; let unused_width = img_area.width - total_width;
self.last_render.unused_width = unused_width;
img_area.x += unused_width / 2;
if zoom.level < 0 { if let Some(total_height) = page_sizes.iter().map(|(_, h, _)| h).max() {
let old_width = img_area.width; // This subtraction might sporadicly fail while shrinking the window.
img_area.width = img_area if let Some(unused_height) = img_area.height.checked_sub(*total_height) {
.width img_area.y += unused_height / 2;
.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::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 to_display = page_sizes
let mut test_area_w = img_area.width; .into_iter()
// 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() .enumerate()
// and only take as many as are ready to be rendered .filter_map(|(idx, (width, _, img))| {
.take_while(|(idx, page)| { let maybe_img =
let mut take = page.img.is_some(); Self::render_single_page(frame, img, Rect { width, ..img_area });
if let Some(max) = self.page_constraints.max_wide { img_area.x += width;
take &= *idx < max.get(); maybe_img.map(|(img, pos)| KittyReadyToDisplay {
} img,
take page_num: idx + self.page,
}) pos,
// and map it to their width (in cells on the terminal, not pixels) display_loc: DisplayLocation::default()
.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
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if self.page_constraints.r_to_l { // we want to set this at the very end so it doesn't get set somewhere halfway through and
page_sizes.reverse(); // then the whole diffing thing messes it up
} self.last_render.rect = size;
if page_sizes.is_empty() { KittyDisplay::DisplayImages(to_display)
// 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::<u16>();
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;
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()
})
})
.collect::<Vec<_>>();
// we want to set this at the very end so it doesn't get set somewhere halfway through and
// then the whole diffing thing messes it up
self.last_render.rect = size;
KittyDisplay::DisplayImages(to_display)
}
} }
} }
@@ -694,9 +762,8 @@ impl Tui {
self.last_render.rect = Rect::default(); self.last_render.rect = Rect::default();
Some(InputAction::SwitchRenderZoom(f_or_f)) Some(InputAction::SwitchRenderZoom(f_or_f))
} }
'o' if self.is_kitty => self.update_zoom(|z| 'o' if self.is_kitty =>
// TODO: for now, we don't let people zoom in past fill-screen self.update_zoom(|z| z.level = z.level.saturating_add(1)),
z.level = z.level.saturating_add(1).min(0)),
'O' if self.is_kitty => 'O' if self.is_kitty =>
self.update_zoom(|z| z.level = z.level.saturating_sub(1)), self.update_zoom(|z| z.level = z.level.saturating_sub(1)),
'L' if self.is_kitty => self.update_zoom(|z| { 'L' if self.is_kitty => self.update_zoom(|z| {