mirror of
https://github.com/itsjunetime/tdf.git
synced 2026-06-02 08:01:47 -04:00
Rewrite zooming (#121)
This commit is contained in:
+242
-175
@@ -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| {
|
||||||
|
|||||||
Reference in New Issue
Block a user