mirror of
https://github.com/itsjunetime/tdf.git
synced 2026-06-02 16:11:45 -04:00
Rewrite zooming (#121)
This commit is contained in:
+242
-175
@@ -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
|
||||
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 {
|
||||
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!()
|
||||
};
|
||||
execute!(stdout(), BeginSynchronizedUpdate).unwrap();
|
||||
|
||||
log::debug!("zoom is now {zoom:#?}");
|
||||
log::debug!("img_area is {img_area:#?}");
|
||||
log::debug!("img dimensions are {cell_w}x{cell_h}");
|
||||
let total_width = page_sizes.iter().map(|(w, _, _)| w).sum::<u16>();
|
||||
|
||||
let img_width = f32::from(cell_w);
|
||||
let img_height = f32::from(cell_h);
|
||||
self.last_render.pages_shown = page_sizes.len();
|
||||
|
||||
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 {
|
||||
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::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(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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
let to_display = page_sizes
|
||||
.into_iter()
|
||||
.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)
|
||||
.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()
|
||||
})
|
||||
})
|
||||
// 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();
|
||||
}
|
||||
// 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;
|
||||
|
||||
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::<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)
|
||||
}
|
||||
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| {
|
||||
|
||||
Reference in New Issue
Block a user