From 86356d15be24374e56f29b626f0624a19b542834 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 00:34:20 +0200 Subject: [PATCH 1/4] Layout: Go Back and Go Forward through the pages viewed, with InDesign's shortcuts --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 66 ++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/lib.rs | 11 +++ crates/ui-egui/src/menus.rs | 151 +++++++++++++++++++++++++++++ crates/ui-egui/src/new_document.rs | 4 +- crates/ui-egui/src/page_history.rs | 148 ++++++++++++++++++++++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 17 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/page_history.rs diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..42368dc25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..dddcfedbe 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -238,8 +238,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } -/// Scroll so absolute page `abs` is centred (keeps zoom). +/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from. pub fn go_to_page(app: &mut DesignApp, abs: usize) { + note_jump(app, false); + centre_page(app, abs); + note_jump(app, true); +} + +fn centre_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, false); let Some(pr) = layout.page_rect(&st.doc, abs) else { return }; @@ -249,6 +255,58 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// The document's pages by id, in page order. +fn page_ids(doc: &Document) -> Vec { + doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect() +} + +/// The page in view and the view centre's offset from that page's centre. Parent pages have none, +/// nor has a view still waiting to be fitted (leaving parent pages for a document page). +fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { + let st = app.session.active().filter(|st| !st.editing_parents)?; + let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?); + let abs = current_page(app)?; + let id = st.doc.page(abs)?.id; + let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?; + let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + Some((id, c - pr.center())) +} + +/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages +/// panel), not scrolling: where the view is when a jump starts, and where the jump took it. +fn note_jump(app: &mut DesignApp, to: bool) { + let Some((id, offset)) = page_in_view(app) else { return }; + if let Some(h) = app.page_history_mut() { + if to { h.visit(id, offset) } else { h.leave(id, offset) } + } +} + +/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is. +pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { + let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false }; + let ids = page_ids(&st.doc); + let Some(mut h) = app.page_history().cloned() else { return false }; + if let Some((id, offset)) = page_in_view(app) { + h.leave(id, offset); + } + h.can_go(step, |id| ids.contains(&id)) +} + +/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where +/// the view was on it (zoom kept). +pub fn go_through_history(app: &mut DesignApp, step: isize) { + note_jump(app, false); + let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return }; + let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return }; + let Some(st) = app.session.active() else { return }; + let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return }; + let Some(rect) = app.canvas_rect else { return }; + let c = pr.center() + offset; + if let Some(v) = app.view_mut() { + v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom); + } +} + /// 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 }; @@ -268,6 +326,12 @@ fn present_fit(app: &mut DesignApp, rect: Rect) { /// 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) { + note_jump(app, false); + centre_spread(app, delta); + note_jump(app, true); +} + +fn centre_spread(app: &mut DesignApp, delta: isize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, st.editing_parents); let Some(cur) = current_slot(app, &layout) else { return }; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..74d15a2d6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -125,6 +125,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]), ("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]), ("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]), + ("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]), + ("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]), ( "Close Tab Group", ["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..3aa1871a7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -170,6 +170,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "الصفحة السابقة"), ("First Page", "الصفحة الأولى"), ("Last Page", "الصفحة الأخيرة"), + ("Go Back", "رجوع"), + ("Go Forward", "تقدم"), ("Font", "خط"), ("Size", "الحجم"), ("Character", "حرف"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..f8be53d2d 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1443,6 +1443,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Předchozí stránka"), ("First Page", "První stránka"), ("Last Page", "Poslední stránka"), + ("Go Back", "Přejít zpět"), + ("Go Forward", "Přejít vpřed"), ("Object Styles", "Styly objektů"), ("Row", "Řádek"), ("Column", "Sloupec"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..2441530a2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1617,6 +1617,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Pagina precedente"), ("First Page", "Prima pagina"), ("Last Page", "Ultima pagina"), + ("Go Back", "Torna indietro"), + ("Go Forward", "Vai avanti"), ("Object Styles", "Stili oggetto"), ("Row", "Riga"), ("Column", "Colonna"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..16879e406 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1549,6 +1549,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "წინა გვერდი"), ("First Page", "პირველი გვერდი"), ("Last Page", "ბოლო გვერდი"), + ("Go Back", "უკან დაბრუნება"), + ("Go Forward", "წინ გადასვლა"), ("Object Styles", "ობიექტის სტილები"), ("Row", "სტრიქონი"), ("Rulers", "სახაზავები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..731e6ceeb 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1480,6 +1480,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Poprzednia strona"), ("First Page", "Pierwsza strona"), ("Last Page", "Ostatnia strona"), + ("Go Back", "Przejdź wstecz"), + ("Go Forward", "Przejdź dalej"), ("Object Styles", "Style obiektów"), ("Row", "Wiersz"), ("Rulers", "Linijki"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..9c83f1d0b 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -176,6 +176,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Página anterior"), ("First Page", "Primeira página"), ("Last Page", "Última página"), + ("Go Back", "Voltar"), + ("Go Forward", "Avançar"), ("Font", "Fonte"), ("Size", "Tamanho"), ("Character", "Caractere"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..971579d9f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1431,6 +1431,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Предыдущая страница"), ("First Page", "Первая страница"), ("Last Page", "Последняя страница"), + ("Go Back", "Назад"), + ("Go Forward", "Вперёд"), ("Object Styles", "Стили объектов"), ("Row", "Строка"), ("Column", "Столбец"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..deefd5759 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1614,6 +1614,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Попередня сторінка"), ("First Page", "Перша сторінка"), ("Last Page", "Остання сторінка"), + ("Go Back", "Назад"), + ("Go Forward", "Вперед"), ("Object Styles", "Стилі об’єктів"), ("Row", "Рядок"), ("Column", "Стовпець"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c87629..0b1add9ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +mod page_history; mod panel_docking; pub mod panels; mod prefs; @@ -559,6 +560,8 @@ pub struct DesignApp { pub ui: UiState, pub services: Services, pub views: HashMap, + /// Layout ▸ Go Back / Go Forward, by view (like `views`). + pub(crate) page_history: HashMap, /// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`). pub canvas: CanvasCache, pub canvas_rect: Option, @@ -662,6 +665,7 @@ impl DesignApp { ui: UiState::default(), services, views: HashMap::new(), + page_history: HashMap::new(), canvas: CanvasCache::new(), canvas_rect: None, split: false, @@ -726,6 +730,13 @@ impl DesignApp { let k = self.view_key()?; Some(self.views.entry(k).or_default()) } + pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> { + self.page_history.get(&self.view_key()?) + } + pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> { + let k = self.view_key()?; + Some(self.page_history.entry(k).or_default()) + } /// Make `pane` current: its canvas cache and rect move in. pub fn switch_pane(&mut self, pane: u8) { if pane == self.pane { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..2a3ffebc2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -237,6 +237,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"), ("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"), ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), + ("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"), + ("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"), ( "window.panel", "Show Panel", @@ -427,6 +429,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.nextSpread", "ui:view.previousSpread", "ui:view.goToPage", + "ui:view.goBack", + "ui:view.goForward", "-", "cmd:layout.section", "cmd:toc.generate", @@ -1627,6 +1631,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + Ok(Value::Null) + } "view.nextSpread" | "view.previousSpread" => { crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 }); Ok(Value::Null) @@ -2464,6 +2472,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + if id == "view.goBack" || id == "view.goForward" { + return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -4195,6 +4206,146 @@ mod tests { assert_eq!(v.zoom, zoom); } + #[test] + fn go_back_and_go_forward_retrace_the_pages_viewed() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + 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, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward")); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // In the Layout menu after Go to Page. + let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1; + let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap(); + assert_eq!(at("view.goBack"), at("view.goToPage") + 1); + assert_eq!(at("view.goForward"), at("view.goBack") + 1); + assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go"); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let cmd_shift = egui::Modifiers { shift: true, ..cmd }; + let shift = egui::Modifiers::SHIFT; + // Pages viewed: 1, 5, 7. + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::PageDown, cmd_shift)); + assert_eq!(page(&app), 6); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (false, true), "back at the first page viewed"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 4, "Go Forward"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 6, "Go Forward again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + // After going back, viewing another page drops the pages ahead. + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 5, "Next Page"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false), "nothing ahead after a new page"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 5, "Go Forward does nothing"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); + } + + #[test] + fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + 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, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: 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() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.fitSpread", json!({})).unwrap(); + frame(&mut app, vec![]); + let before = *app.view().unwrap(); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 2, "Next Spread"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin); + assert_eq!(v.zoom, before.zoom); + // Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and + // centres the same place. + let centre = |app: &crate::DesignApp| { + let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap()); + designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom) + }; + let place = centre(&app); + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.zoomIn", json!({})).unwrap(); + let zoomed = app.view().unwrap().zoom; + assert!(zoomed > before.zoom); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept"); + assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app)); + // Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips + // from where the scroll ended straight to the page before the first jump. + let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots; + app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 3); + assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back"); + assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2); + } + #[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()); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ee59de800..4fd5cc4e8 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -278,11 +278,13 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) { } } -/// Drop the views (one per pane) of a preview document that has gone. +/// Drop the views and page histories (one per pane) of a preview document that has gone. fn forget_view(app: &mut DesignApp, uid: Option) { if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) { app.views.remove(&uid); app.views.remove(&(uid ^ (1 << 63))); + app.page_history.remove(&uid); + app.page_history.remove(&(uid ^ (1 << 63))); } } diff --git a/crates/ui-egui/src/page_history.rs b/crates/ui-egui/src/page_history.rs new file mode 100644 index 000000000..177bfcf9c --- /dev/null +++ b/crates/ui-egui/src/page_history.rs @@ -0,0 +1,148 @@ +//! Layout ▸ Go Back / Go Forward: the pages a window has jumped between, in the order it showed them. +//! InDesign keeps this order for the session, like a web browser's Back and Forward. + +use designcraft_doc::ids::PageId; +use designcraft_geom::Vec2; + +/// Entries kept: the oldest go first. +const LIMIT: usize = 256; + +#[derive(Clone, Debug, Default)] +pub struct PageHistory { + /// Each page with where the view centre was on it, as an offset from the page's centre. + pages: Vec<(PageId, Vec2)>, + /// The entry in view. + at: usize, +} + +impl PageHistory { + /// A jump or Go Back starts from `page`, the view centre `offset` from the page's centre: the + /// entry in view takes that place, wherever scrolling has moved the view since it was recorded, + /// as a browser keeps a page's scroll position. + pub fn leave(&mut self, page: PageId, offset: Vec2) { + match self.pages.get_mut(self.at) { + Some(e) => *e = (page, offset), + None => self.visit(page, offset), + } + } + + /// A jump has brought the view to `page`, its centre `offset` from the page's centre. Another page + /// than the entry in view drops the entries ahead of it, as a browser does when a link is followed + /// after going back. + pub fn visit(&mut self, page: PageId, offset: Vec2) { + if let Some(e) = self.pages.get_mut(self.at) + && e.0 == page + { + e.1 = offset; + return; + } + self.pages.truncate(self.at.saturating_add(1)); + self.pages.push((page, offset)); + if self.pages.len() > LIMIT { + self.pages.remove(0); + } + self.at = self.pages.len().saturating_sub(1); + } + + /// The entry Go Back (`step` < 0) or Go Forward goes to: the nearest one that way whose page is + /// still in the document and isn't the page in view. + fn target(&self, step: isize, exists: impl Fn(PageId) -> bool) -> Option { + let current = self.pages.get(self.at).map(|e| e.0); + let usable = |i: &usize| self.pages.get(*i).is_some_and(|&(p, _)| Some(p) != current && exists(p)); + if step < 0 { (0..self.at).rev().find(usable) } else { (self.at.saturating_add(1)..self.pages.len()).find(usable) } + } + + /// Whether Go Back (`step` < 0) or Go Forward has a page to go to. + pub fn can_go(&self, step: isize, exists: impl Fn(PageId) -> bool) -> bool { + self.target(step, exists).is_some() + } + + /// Go Back (`step` < 0) or Go Forward: the page to show and where the view was on it. It becomes + /// the entry in view. + pub fn go(&mut self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<(PageId, Vec2)> { + let i = self.target(step, exists)?; + self.at = i; + self.pages.get(i).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: fn(PageId) -> bool = |_| true; + + fn visited(pages: &[u64]) -> PageHistory { + let mut h = PageHistory::default(); + for &p in pages { + h.visit(PageId(p), Vec2::ZERO); + } + h + } + + fn page(e: Option<(PageId, Vec2)>) -> Option { + e.map(|(p, _)| p.0) + } + + #[test] + fn go_back_and_forward_retrace_the_pages_in_the_order_they_were_viewed() { + let mut h = visited(&[1, 5, 3]); + assert!(!h.can_go(1, ALL), "nothing ahead of the last page viewed"); + assert_eq!(page(h.go(-1, ALL)), Some(5)); + assert_eq!(page(h.go(-1, ALL)), Some(1)); + assert_eq!(h.go(-1, ALL), None, "the first page viewed has nothing before it"); + assert_eq!(page(h.go(1, ALL)), Some(5)); + assert_eq!(page(h.go(1, ALL)), Some(3)); + assert_eq!(h.go(1, ALL), None); + } + + #[test] + fn going_back_does_not_record_the_page_it_shows() { + let mut h = visited(&[1, 5, 3]); + let (p, offset) = h.go(-1, ALL).unwrap(); + // The next jump starts from it. + h.leave(p, offset); + assert_eq!(page(h.go(1, ALL)), Some(3), "Go Forward still has the page left"); + } + + #[test] + fn an_entry_keeps_the_place_a_jump_left_it_at() { + let mut h = visited(&[1, 5]); + // Scrolled from page 5 to page 7, then jumped. + h.leave(PageId(7), Vec2::new(-200.0, 30.0)); + h.visit(PageId(9), Vec2::ZERO); + assert_eq!(h.go(-1, ALL), Some((PageId(7), Vec2::new(-200.0, 30.0))), "back to where the view was"); + assert_eq!(page(h.go(-1, ALL)), Some(1), "scrolling added no entry"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert_eq!(page(h.go(1, ALL)), Some(9), "nor dropped the ones ahead"); + } + + #[test] + fn viewing_another_page_after_going_back_clears_go_forward() { + let mut h = visited(&[1, 5, 3]); + h.go(-1, ALL); + h.visit(PageId(7), Vec2::ZERO); + assert_eq!(page(h.go(-1, ALL)), Some(5), "the page gone back to stays behind the new one"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert!(!h.can_go(1, ALL), "the page that was ahead is gone"); + } + + #[test] + fn deleted_pages_are_skipped() { + let mut h = visited(&[1, 5, 3]); + let exists = |p: PageId| p != PageId(5); + assert_eq!(page(h.go(-1, exists)), Some(1)); + assert_eq!(page(h.go(1, exists)), Some(3)); + assert!(!visited(&[5, 3]).can_go(-1, exists), "no page left to go back to"); + } + + #[test] + fn the_history_is_bounded() { + let mut h = visited(&(0..LIMIT as u64 + 10).collect::>()); + let mut back = 0; + while h.go(-1, ALL).is_some() { + back += 1; + } + assert_eq!(back, LIMIT - 1); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..1fbc07ca6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -168,7 +168,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..67a5e28cd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -204,7 +204,7 @@ table is the one summed. | Area | Breadth (checklist) | Ready | Hours | Main gaps | |---|---|---|---|---| | Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | -| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | +| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, layout grid (CJK) | | Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | | Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu | | Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | From 5539e286475bba2cfe7084cba8ae7352f6070fa5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 11:26:32 +0200 Subject: [PATCH 2/4] Layout: Go Back after Presentation returns to the last spread presented Presentation's spreads now count as one jump in the window's history, and leaving it as a jump back to the view it was entered from, at the place the view was. Before, the history stayed on the last presented spread while the view was put back, so Go Back couldn't reach it. docs/gaps.md: Go Back and Go Forward are two of the missing menu items, 149. --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/lib.rs | 8 ++++ crates/ui-egui/src/menus.rs | 77 +++++++++++++++++++++++++++++++++++- docs/gaps.md | 2 +- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dddcfedbe..e0ac7344a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -262,7 +262,7 @@ fn page_ids(doc: &Document) -> Vec { /// The page in view and the view centre's offset from that page's centre. Parent pages have none, /// nor has a view still waiting to be fitted (leaving parent pages for a document page). -fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { +pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { let st = app.session.active().filter(|st| !st.editing_parents)?; let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?); let abs = current_page(app)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b1add9ac..e144eb418 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,12 @@ pub struct UiState { /// The view (by its `views` key) Presentation was entered from; leaving puts it back. #[serde(skip)] pub presentation_view: Option<(u64, View)>, + /// The page in that view and the place on it, for Go Back after leaving Presentation. + #[serde(skip)] + pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>, + /// That view's Go Back / Go Forward history; leaving puts it back with one jump added. + #[serde(skip)] + pub(crate) presentation_history: Option, pub frame_edges: bool, pub rulers: bool, pub guides: bool, @@ -362,6 +368,8 @@ impl Default for UiState { screen_mode: ScreenMode::Normal, presentation_return: ScreenMode::Normal, presentation_view: None, + presentation_page: None, + presentation_history: None, frame_edges: true, rulers: true, guides: true, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a3ffebc2..45becf63d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1978,13 +1978,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) { app.ui.presentation_return = app.ui.screen_mode; // Presentation fits the spread to the screen; leaving it puts the user's view back. app.ui.presentation_view = app.view_key().zip(app.view().copied()); + app.ui.presentation_page = crate::canvas::page_in_view(app); + app.ui.presentation_history = app.page_history().cloned(); } if mode != ScreenMode::Presentation && presenting && let Some((k, v)) = app.ui.presentation_view.take() - && let Some(view) = app.views.get_mut(&k) + && app.views.contains_key(&k) { - *view = v; + // Go Back sees the presentation as one jump to the last spread presented, and putting the + // view back as a jump from there: the spreads in between would otherwise have replaced the + // place the view was at with the presentation's. + let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k)); + let h = app.page_history.entry(k).or_default(); + *h = app.ui.presentation_history.take().unwrap_or_default(); + if let Some(((from, at), (to, offset))) = shown { + h.leave(from, at); + h.visit(to, offset); + h.visit(from, at); + } + if let Some(view) = app.views.get_mut(&k) { + *view = v; + } } app.ui.screen_mode = mode; } @@ -4346,6 +4361,64 @@ mod tests { assert_eq!(spread(&app), 2); } + #[test] + fn go_back_after_presentation_returns_to_the_last_spread_presented() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + 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, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: 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() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Scrolled off the page's centre, so the place Go Back returns to is not just the page. + let view = app.view_mut().unwrap(); + view.origin.x += 40.0; + view.origin.y += 60.0; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + let before = *app.view().unwrap(); + // Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was. + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(spread(&app), 2); + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + assert!(!menu_enabled(&app, "view.goForward")); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 2, "Go Back shows the last spread presented"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from"); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin); + assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2, "Go Forward"); + } + #[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()); diff --git a/docs/gaps.md b/docs/gaps.md index 1fbc07ca6..4ee123d03 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,7 +165,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, From 13f6739f49efc2e54cabdb8460722ee7e38fffc0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 15:39:47 +0200 Subject: [PATCH 3/4] Layout: Next or Previous Spread at the last or first spread is no jump for Go Back At the end of the document the spread command only centres the spread in view, which can move the view from one facing page to the other. That move was recorded as a jump, so Go Back went to the other page of the same spread instead of the page viewed before. --- crates/ui-egui/src/canvas.rs | 21 ++++++++++++++---- crates/ui-egui/src/menus.rs | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e0ac7344a..721e46469 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -326,16 +326,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) { /// 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) { - note_jump(app, false); + // At the first or last spread only the view moves, within the spread: no jump for Go Back. + let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to); + if jump { + note_jump(app, false); + } centre_spread(app, delta); - note_jump(app, true); + if jump { + note_jump(app, true); + } +} + +/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends. +fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cur = current_slot(app, &layout)?; + Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)))) } fn centre_spread(app: &mut DesignApp, delta: isize) { + let Some((_, to)) = spread_step(app, delta) else { return }; let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let Some(cur) = current_slot(app, &layout) else { return }; - let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)); let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return }; let Some(rect) = app.canvas_rect else { return }; if let Some(v) = app.view_mut() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 45becf63d..d6bd7cfec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4289,6 +4289,47 @@ mod tests { assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); } + #[test] + fn next_spread_at_the_last_spread_is_no_jump() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + 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, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread. + app.run("view.goToPage", json!({"page": 6})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(page(&app), 5); + // Next Spread has no spread to go to; centring the last one moves the view to its fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_ne!(page(&app), 5, "the view moved to the right page"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page"); + assert!(!menu_enabled(&app, "view.goBack")); + } + #[test] fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From a0790bcb55e4dd20ccb66d7ff1d6f7f81d653de5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 18:08:05 +0200 Subject: [PATCH 4/4] Layout: Go Back and Go Forward wait for the view to be fitted --- crates/ui-egui/src/canvas.rs | 7 +++++++ crates/ui-egui/src/menus.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 721e46469..31362868c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -284,6 +284,10 @@ fn note_jump(app: &mut DesignApp, to: bool) { /// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is. pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false }; + // A view still waiting to be fitted is refitted next frame, which would replace the destination. + if !app.view().is_some_and(|v| v.fitted) { + return false; + } let ids = page_ids(&st.doc); let Some(mut h) = app.page_history().cloned() else { return false }; if let Some((id, offset)) = page_in_view(app) { @@ -295,6 +299,9 @@ pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { /// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where /// the view was on it (zoom kept). pub fn go_through_history(app: &mut DesignApp, step: isize) { + if !app.view().is_some_and(|v| v.fitted) { + return; + } note_jump(app, false); let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return }; let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6bd7cfec..8c0b3af46 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4289,6 +4289,33 @@ mod tests { assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); } + #[test] + fn go_back_waits_for_the_view_to_be_fitted() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + frame(&mut app); + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack")); + // Leaving parent pages through the Pages panel: the canvas refits on the next frame. + app.view_mut().unwrap().fitted = false; + assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went"); + crate::canvas::go_through_history(&mut app, -1); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry"); + } + #[test] fn next_spread_at_the_last_spread_is_no_jump() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());