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:
parent
c0f9c73ae9
commit
d26821fdcd
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue