From 75d3e528ff9d0be3226c07400d3b5c3307ae0916 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:32:52 +0200 Subject: [PATCH] View: Presentation shows one spread fitted to the screen and goes through spreads with arrow keys and clicks Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 45 +++++++++++++-- crates/ui-egui/src/menus.rs | 108 +++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b656875c1..17f35c1c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- 2026-10-15: Presentation shows one spread at a time, as large as the screen allows; the arrow keys, Page Up/Down, Home, End, a click and Shift-click go through the spreads. - 2026-10-15: Layout › First Page, Previous Page, Next Page, Last Page, Next Spread and Previous Spread, with InDesign's shortcuts (Shift+Page Up/Down, Shift+Cmd+Page Up/Down, Alt+Page Up/Down). - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1c550a197..8cb5859ee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -221,6 +221,22 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// Presentation: the spread in view, centred and as large as `rect` allows. +fn present_fit(app: &mut DesignApp, rect: Rect) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(b) = current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.bounds) else { return }; + if b.width() <= 0.0 || b.height() <= 0.0 { + return; + } + let zoom = (rect.width() as f64 / b.width()).min(rect.height() as f64 / b.height()).clamp(0.05, 40.0); + let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom); + if let Some(v) = app.view_mut() { + v.zoom = zoom; + v.origin = origin; + } +} + /// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the /// first and last spread. Parent spreads while they are being edited. pub fn step_spread(app: &mut DesignApp, delta: isize) { @@ -337,10 +353,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let rect = view_rect(screen, rot); app.canvas_rect = Some(rect); let resp = ui.allocate_rect(full, Sense::click_and_drag()); - if !app.view().is_some_and(|v| v.fitted) { - fit(app, rect, "spread"); + if presenting { + // One spread at a time, as large as the screen allows; a click shows the next one, + // Shift-click the previous one. Tools and selection don't see the input. + present_fit(app, rect); + if resp.clicked() { + // The release that made the click says whether Shift was down. + let shift = ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| matches!(e, egui::Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)) + }); + step_spread(app, if shift { -1 } else { 1 }); + present_fit(app, rect); + } + } else { + if !app.view().is_some_and(|v| v.fitted) { + fit(app, rect, "spread"); + } + handle_input(app, ui, &resp, rect); } - handle_input(app, ui, &resp, rect); if bars { scrollbars(app, ui, outer, screen, rect, &t); } @@ -348,7 +379,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(&v) = app.view() else { return }; let xf = Xf::new(rect, &v); let doc = st.doc.clone(); - let layout = CanvasLayout::new(&doc, st.editing_parents); + let mut layout = CanvasLayout::new(&doc, st.editing_parents); + if presenting && let Some(cur) = current_slot(app, &layout) { + layout.slots = layout.slots.get(cur).copied().into_iter().collect(); + } let painter = ui.painter_at(screen); let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; @@ -419,6 +453,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout); } } + if presenting { + return; + } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); draw_tool_overlays(app, &painter, &xf); if rulers { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 423b72be9..27d89a5d8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2392,6 +2392,21 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue }; + // Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End. + if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt { + let step = match key { + egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1), + egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1), + egui::Key::Home => Some(isize::MIN), + egui::Key::End => Some(isize::MAX), + _ => None, + }; + if let Some(step) = step { + ctx.input_mut(|i| i.consume_key(modifiers, key)); + crate::canvas::step_spread(app, step); + continue; + } + } // Command shortcuts. let mut fired: Option = None; let all = effective_shortcuts(app); @@ -3402,6 +3417,99 @@ mod tests { assert_eq!(v.zoom, zoom); } + #[test] + fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap(); + let id = app.session.active().unwrap().selection.items[0]; + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] + }; + let click = |pos: egui::Pos2, modifiers: egui::Modifiers| { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + // The spread fills the screen: as wide or as tall as it, centred. + let fills = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let b = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[spread(app)].bounds; + let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let (w, h) = (b.width() * v.zoom, b.height() * v.zoom); + let x0 = (b.x0 - v.origin.x) * v.zoom; + let y0 = (b.y0 - v.origin.y) * v.zoom; + ((w - r.width() as f64).abs() < 0.01 || (h - r.height() as f64).abs() < 0.01) + && w <= r.width() as f64 + 0.01 + && h <= r.height() as f64 + 0.01 + && (x0 * 2.0 + w - r.width() as f64).abs() < 0.01 + && (y0 * 2.0 + h - r.height() as f64).abs() < 0.01 + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(app.canvas_rect.unwrap().size(), egui::vec2(1400.0, 900.0), "the whole window"); + assert_eq!(spread(&app), 0); + assert!(fills(&app)); + let bounds = app.session.active().unwrap().doc.item(id).unwrap().bounds(); + frame(&mut app, key(egui::Key::ArrowRight)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "Right Arrow shows the next spread"); + assert!(fills(&app)); + assert_eq!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds, "the arrow doesn't nudge the selection"); + frame(&mut app, key(egui::Key::PageDown)); + assert_eq!(spread(&app), 2, "Page Down"); + frame(&mut app, key(egui::Key::ArrowLeft)); + assert_eq!(spread(&app), 1, "Left Arrow"); + frame(&mut app, key(egui::Key::End)); + assert_eq!(spread(&app), 3, "End"); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_eq!(spread(&app), 3, "the last spread stays"); + frame(&mut app, key(egui::Key::Home)); + assert_eq!(spread(&app), 0, "Home"); + frame(&mut app, key(egui::Key::PageUp)); + assert_eq!(spread(&app), 0, "the first spread stays"); + let c = app.canvas_rect.unwrap().center(); + frame(&mut app, click(c, egui::Modifiers::NONE)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "a click shows the next spread"); + assert!(fills(&app)); + frame(&mut app, click(c, egui::Modifiers::SHIFT)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0, "Shift-click the previous one"); + assert_eq!(app.session.active().unwrap().selection.items, vec![id], "clicks don't select"); + assert_eq!(app.session.tool_id(), "selection"); + // Out of Presentation, the arrow keys nudge again. + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_ne!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch