M3: View ▸ Zoom ▸ Fit visible (⌘3): zoom so the current page's visible content spans the window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 04:32:03 -04:00
parent c0f9c73ae9
commit d26821fdcd
8 changed files with 120 additions and 4 deletions

View File

@ -165,6 +165,7 @@ pub const COMMANDS: &[CommandSpec] = &[
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
c("view.fit_visible", "Fit visible", VIEW, Some(Shortcut::cmd("3")), Document, "scan"),
c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),

View File

@ -532,6 +532,20 @@ impl DocView {
self.zoom_anchor = Some((page, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), self.viewport_screen.center() - self.viewport_screen.min));
}
/// View ▸ Zoom ▸ Fit Visible: zoom so the visible content of `page` (display-normalised
/// [x0, y0, x1, y1], i.e. after view rotation) spans the window's width, its left edge at
/// the window's left and its top at the top.
pub fn fit_content(&mut self, page: usize, content: [f32; 4]) -> bool {
const PAD: f32 = 16.0;
let Some((_, pr)) = self.screen_rects.iter().find(|(p, _)| *p == page).copied() else { return false };
let w = (content[2] - content[0]).max(0.01) * pr.width();
let k = (self.viewport_screen.width() - 2.0 * PAD).max(50.0) / w.max(1.0);
self.zoom = (self.zoom * k).clamp(0.08, 64.0);
self.fit = Fit::None;
self.zoom_anchor = Some((page, content[0].clamp(0.0, 1.0), content[1].clamp(0.0, 1.0), vec2(PAD, MARGIN)));
true
}
/// Zoom keeping the centre of the view still.
pub fn set_zoom(&mut self, zoom: f32) {
let centre = self.viewport_screen.center();

View File

@ -177,6 +177,11 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, "Fit visible", "⌘3").clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, "Zoom in", "⌘+").clicked() {
v.zoom_step(true);
}

View File

@ -308,6 +308,13 @@ impl PrintCraftApp {
}
"sign.panel" => self.right = Some(RightPanel::Signatures),
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
"view.fit_visible" => {
if let Some(i) = self.active
&& let Err(e) = self.fit_visible(i)
{
self.notify(format!("Couldn't fit the visible content: {e}"));
}
}
"view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom,
"edit.snapshot" => {
self.quick_tool = crate::QuickTool::Snapshot;

View File

@ -869,6 +869,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
("⌘1", "Actual size"),
("⌘0", "Zoom to page level"),
("⌘2", "Fit to width"),
("⌘3", "Fit visible"),
("⌘+ / ⌘−", "Zoom in / out (also pinch or ⌘-scroll)"),
("⇧⌘+ / ⇧⌘−", "Rotate view"),
("Home / End", "First / last page"),

View File

@ -72,6 +72,42 @@ impl PrintCraftApp {
}
}
/// View ▸ Zoom ▸ Fit Visible on the current page: find where the page has ink (a quick
/// low-resolution render) and zoom so that spans the window's width. A blank page fits the
/// width.
pub fn fit_visible(&mut self, index: usize) -> Result<(), String> {
let view = &self.views[index];
let page = view.current;
let doc = self.session.get(view.id).ok_or("no document")?;
let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() };
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config);
// About 800 px along the longer side is plenty to find the margins.
let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height));
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0 });
if let Some(e) = out.error {
return Err(e);
}
let rot = view.rotation;
let view = &mut self.views[index];
match ink_bounds(out.width as usize, out.height as usize, &out.rgba) {
Some([u0, v0, u1, v1]) => {
// View space → the rotated view on screen.
let rotate = |u: f32, v: f32| match rot {
90 => (1.0 - v, u),
180 => (1.0 - u, 1.0 - v),
270 => (v, 1.0 - u),
_ => (u, v),
};
let (a, b) = (rotate(u0, v0), rotate(u1, v1));
if !view.fit_content(page, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]) {
view.fit = crate::canvas::Fit::Width;
}
}
None => view.fit = crate::canvas::Fit::Width,
}
Ok(())
}
/// Render `view_rect` (page view points) of `page` at the current zoom and copy it to the
/// clipboard (and keep it in `last_snapshot`).
pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> {
@ -104,3 +140,37 @@ impl PrintCraftApp {
Ok((w, h))
}
}
/// The normalised bounds [u0, v0, u1, v1] of the pixels that aren't (near) white, if any.
pub(crate) fn ink_bounds(w: usize, h: usize, rgba: &[u8]) -> Option<[f32; 4]> {
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let ink = |p: &[u8]| p[3] > 16 && (p[0] < 240 || p[1] < 240 || p[2] < 240);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (y, row) in rgba.chunks_exact(w * 4).take(h).enumerate() {
for (x, p) in row.chunks_exact(4).enumerate() {
if ink(p) {
x0 = x0.min(x);
x1 = x1.max(x + 1);
y0 = y0.min(y);
y1 = y1.max(y + 1);
}
}
}
(x1 > x0 && y1 > y0).then(|| [x0 as f32 / w as f32, y0 as f32 / h as f32, x1 as f32 / w as f32, y1 as f32 / h as f32])
}
#[cfg(test)]
mod tests {
#[test]
fn ink_bounds_finds_the_marked_area() {
let (w, h) = (10, 20);
let mut px = vec![255u8; w * h * 4];
for (x, y) in [(2, 5), (7, 15)] {
px[(y * w + x) * 4] = 0;
}
assert_eq!(super::ink_bounds(w, h, &px), Some([0.2, 0.25, 0.8, 0.8]));
assert_eq!(super::ink_bounds(w, h, &vec![255u8; w * h * 4]), None);
}
}

View File

@ -261,3 +261,21 @@ fn marquee_zoom_and_snapshot() {
let after = h.state().views[i].zoom;
assert!(after > before * 2.0, "{before} → {after}");
}
#[test]
fn fit_visible_zooms_to_the_content_width() {
let mut h = harness();
let i = h.state().active.unwrap();
let before = h.state().views[i].zoom;
assert!(h.state_mut().execute("view.fit_visible"));
for _ in 0..4 {
h.run_steps(2);
}
let v = &h.state().views[i];
assert!(v.zoom > before * 1.2, "{before} → {}", v.zoom);
// The text starts 20 pt in from the page's left; that edge is now near the window's left.
let r = v.page_screen_rect(v.current).expect("on screen");
let ink_left = r.left() + 20.0 / 200.0 * r.width();
let vp = v.viewport_rect();
assert!((ink_left - vp.left() - 16.0).abs() < 12.0, "ink at {ink_left}, viewport {vp:?}");
}

View File

@ -971,11 +971,11 @@ title = "Fit visible content"
area = "B"
tier = "P1"
milestone = "M3"
status = "planned"
commands = []
status = "shipped"
commands = ["view.fit_visible"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"]
notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command"
[[feature]]
id = "view.marquee-zoom"