diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index acb05f9f4..856c53e90 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -779,369 +779,4 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { } #[cfg(test)] -mod tests { - use egui::{Event, Modifiers, PointerButton}; - use egui_kittest::{ - Harness, - kittest::{NodeT, Queryable}, - }; - - use super::*; - use crate::test_window; - - #[test] - fn parent_context_menu_deletes_the_clicked_id_even_with_a_shared_prefix() { - for vertical in [true, false] { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); - app.ui.pages_panel.parents_vertical = vertical; - let original = app.session.doc().unwrap().doc.parents[0].id; - let added = app.run("layout.parents.new", json!({"prefix": "A", "name": "Other"})).unwrap()["id"].as_u64().unwrap(); - let mut h = harness(app); - // Target the actual parent row in either panel layout, not a menu helper. - h.get_by_label("A-Other").click_secondary(); - h.run_steps(3); - h.get_by_label("Delete Parent").click(); - h.run_steps(3); - let doc = &h.state().session.doc().unwrap().doc; - assert_eq!(doc.parents.len(), 1); - assert_eq!(doc.parents[0].id, original); - assert!(doc.parents.iter().all(|p| p.id.0 != added)); - assert_eq!(doc.page_count(), 2, "document pages are not deleted"); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(h.state().session.doc().unwrap().doc.parents.len(), 2); - } - } - - #[test] - fn double_clicking_a_parent_shows_that_parent() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - for prefix in ["B", "C", "D"] { - app.run("layout.parents.new", json!({"prefix": prefix})).unwrap(); - } - app.ui.dock_tab = "pages".into(); - let mut h = test_window::open(app, vec2(1400.0, 900.0)); - let names = ["A-Parent", "B-Parent", "C-Parent", "D-Parent"]; - for name in names { - assert_ne!(h.get_by_label(name).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{name} is not selected yet"); - } - // Both clicks in one frame: the harness's event queue spaces events too far apart for a - // double click. - let at = h.get_by_label("C-Parent").rect().center(); - h.input_mut().events.push(Event::PointerMoved(at)); - for pressed in [true, false, true, false] { - h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); - } - h.run_steps(4); - let app = &h.state().app; - assert!(app.session.active().unwrap().editing_parents); - let st = app.session.active().unwrap(); - let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); - let slot = crate::canvas::current_slot(app, &layout).unwrap(); - assert_eq!(layout.slots[slot].spread, designcraft_doc::SpreadRef::Parent(2)); - for name in names { - assert_eq!(h.get_by_label(name).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True), name == "C-Parent", "{name}"); - } - // Parent rows show a rendered thumbnail. - let id = st.doc.parents[2].id; - let rev = (st.uid, st.revision); - let thumbs = super::THUMBS.lock().unwrap(); - assert!(thumbs.as_ref().unwrap().keys().any(|k| (k.0, k.1) == rev && k.2 == super::ThumbOf::Parent(id))); - } - - fn harness(app: DesignApp) -> Harness<'static, DesignApp> { - sized_harness(app, vec2(280.0, 520.0)) - } - - fn sized_harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(size).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::unique("test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - h - } - - /// Release a dragged page over `at`. - fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { - let modifiers = egui::Modifiers { alt, ..Default::default() }; - h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); - egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); - h.event(egui::Event::PointerMoved(at)); - h.run_steps(2); - h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); - h.run_steps(3); - } - - /// The page menu for page `abs` of spread `si`. - fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( - move |ui, app: &mut DesignApp| { - let key = egui::Id::unique("test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - let doc = app.session.doc().unwrap().doc.clone(); - page_menu(app, ui, &doc, abs, si); - }, - app, - ); - h.run_steps(3); - h - } - - #[test] - fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); - let doc = app.session.doc().unwrap().doc.clone(); - let (si, _) = doc.page_loc(2).unwrap(); - assert_eq!(doc.spreads[si].pages.len(), 2); - let mut h = menu_harness(app, 2, si); - let _ = h.get_by_label("Delete Spread"); - h.get_by_label("Delete Page").click(); - h.run_steps(3); - assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); - } - - /// As in InDesign: deleting a page with objects asks first; an empty page goes at once, and - /// "Don't show again" stops the question. - #[test] - fn deleting_a_page_with_objects_asks_first() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); - let doc = app.session.doc().unwrap().doc.clone(); - let (si, pi) = doc.page_loc(2).unwrap(); - let x = doc.spreads[si].pages[pi].x; - app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); - let count = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.page_count(); - let mut h = menu_harness(app, 2, si); - h.get_by_label("Delete Page").click(); - h.run_steps(3); - assert_eq!(count(&h), 5, "nothing deleted yet"); - let d = h.state().ui.dialog.clone().unwrap(); - assert_eq!((d.id.as_str(), d.fields["pages"].clone()), ("deletePages", json!([2]))); - // Cancel keeps the page. - crate::dialogs::cancel(h.state_mut()); - assert_eq!(count(&h), 5); - // The whole spread: one question for both pages. - h.get_by_label("Delete Spread").click(); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["pages"], json!([1, 2])); - crate::dialogs::cancel(h.state_mut()); - // OK deletes it. - h.get_by_label("Delete Page").click(); - h.run_steps(3); - crate::dialogs::confirm(h.state_mut()).unwrap(); - assert_eq!(count(&h), 4); - assert!(!h.state().ui.skip_delete_pages_warning); - // An empty page goes without a question. - let app = h.state_mut(); - crate::dialogs::delete_pages(app, vec![0]).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.doc().unwrap().doc.page_count(), 3); - // "Don't show again". - let doc = app.session.doc().unwrap().doc.clone(); - let (si, pi) = doc.page_loc(1).unwrap(); - let x = doc.spreads[si].pages[pi].x; - app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); - app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 300.0, x + 200.0, 400.0]})).unwrap(); - crate::dialogs::delete_pages(app, vec![1]).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("dontShowAgain".into(), json!(true)); - crate::dialogs::confirm(app).unwrap(); - assert!(app.ui.skip_delete_pages_warning); - assert_eq!(app.session.doc().unwrap().doc.page_count(), 2); - let doc = app.session.doc().unwrap().doc.clone(); - let (si, pi) = doc.page_loc(0).unwrap(); - let x = doc.spreads[si].pages[pi].x; - app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); - crate::dialogs::delete_pages(app, vec![0]).unwrap(); - assert!(app.ui.dialog.is_none(), "no more questions"); - assert_eq!(app.session.doc().unwrap().doc.page_count(), 1); - } - - /// As InDesign's: the footer stays at the bottom however many pages there are, and the - /// divider between parents and pages shows more or fewer parents. - #[test] - fn the_footer_stays_put_and_the_divider_resizes_the_parents() { - use egui::{Event, Modifiers, PointerButton}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 60, "facingPages": true})).unwrap(); - for prefix in ["B", "C", "D", "E", "F", "G"] { - app.run("layout.parents.new", json!({"prefix": prefix})).unwrap(); - } - app.ui.dock_tab = "pages".into(); - let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); - let window = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, 900.0)); - for label in ["60 Pages in 31 Spreads", "Create new page", "Delete selected pages"] { - assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} is on screen"); - } - // Seven parent rows: four and a half show at first; the rest scroll. - let divider_node = h.get_by_label("Parents and pages divider"); - assert_eq!(divider_node.accesskit_node().role(), egui::accesskit::Role::Splitter, "the divider is a splitter, not an unknown node"); - let divider = divider_node.rect(); - let before = divider.center().y; - assert!(h.query_by_label("G-Parent").is_none_or(|n| n.rect().top() > divider.top()), "the last parent is scrolled away"); - let (a, b) = (divider.center(), divider.center() + vec2(0.0, 80.0)); - h.input_mut().events.push(Event::PointerMoved(a)); - h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.step(); - for k in 1..=4 { - h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (k as f32 / 4.0))); - h.step(); - } - h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(3); - let after = h.get_by_label("Parents and pages divider").rect().center().y; - assert!(after > before + 40.0, "the divider moved down: {before} → {after}"); - assert!(h.state().app.ui.pages_parents_height.is_some()); - assert!(window.contains_rect(h.get_by_label("Create new page").rect()), "the footer is still there"); - } - - /// InDesign's Panel Options: thumbnail sizes, blank pages without thumbnails, parents side by - /// side, pages on top. - #[test] - fn panel_options_size_and_arrange_the_panel() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); - app.run("layout.parents.new", json!({"prefix": "B"})).unwrap(); - app.ui.dock_tab = "pages".into(); - let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); - let page = |h: &Harness<'_, crate::test_window::Window>| h.get_by_label("Page 1").rect(); - let medium = page(&h).height(); - // The right-click menu on the panel's empty part leads to Panel Options. - let empty = h.get_by_label("Create new page").rect().center() - vec2(0.0, 60.0); - h.input_mut().events.push(egui::Event::PointerMoved(empty)); - for pressed in [true, false] { - h.input_mut().events.push(egui::Event::PointerButton { - pos: empty, - button: egui::PointerButton::Secondary, - pressed, - modifiers: egui::Modifiers::NONE, - }); - } - h.run_steps(3); - h.get_by_label("Panel Options…").click(); - h.run_steps(3); - let d = h.state().app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.id.as_str(), d.fields["pageSize"].clone(), d.fields["parentSize"].clone(), d.fields["layout"].clone()), - ("pagesPanelOptions", json!("medium"), json!("small"), json!("parentsOnTop")) - ); - // Jumbo pages, parents side by side, pages on top. - let set = |h: &mut Harness<'_, crate::test_window::Window>, k: &str, v: serde_json::Value| { - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - set(&mut h, "pageSize", json!("jumbo")); - set(&mut h, "parentsVertical", json!(false)); - set(&mut h, "layout", json!("pagesOnTop")); - crate::dialogs::confirm(&mut h.state_mut().app).unwrap(); - h.run_steps(4); - assert!(page(&h).height() > 2.5 * medium, "Jumbo: {} vs {medium}", page(&h).height()); - let o = &h.state().app.ui.pages_panel; - assert_eq!((o.page_size, o.parents_vertical, o.pages_on_top), (IconSize::Jumbo, false, true)); - // Pages on top: the parents come after the pages; side by side: [None] and A sit in one row. - let (none, a) = (h.get_by_label("[None]").rect(), h.get_by_label("A-Parent").rect()); - assert!(none.top() > page(&h).top(), "parents below the pages"); - assert!((none.top() - a.top()).abs() < 1.0 && a.left() > none.right(), "side by side"); - // Extra Small: no thumbnails, just pages. - crate::panels::pages::open_panel_options(&mut h.state_mut().app); - set(&mut h, "pageSize", json!("extraSmall")); - crate::dialogs::confirm(&mut h.state_mut().app).unwrap(); - h.run_steps(4); - assert!(page(&h).height() < 0.5 * medium); - } - - /// Panel Options' parent size sets the height of the parents' rendered thumbnails (cached per - /// parent and size), and Show Thumbnails off leaves them blank. - /// Parent rows taller than the room left for the parents (Extra Large or Jumbo in a short - /// panel) draw and drag without a panic. - #[test] - fn large_parent_rows_in_a_short_panel_do_not_panic() { - use egui::{Event, Modifiers, PointerButton}; - for size in [IconSize::ExtraLarge, IconSize::Jumbo] { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 4})).unwrap(); - app.ui.pages_panel.parent_size = size; - let mut h = sized_harness(app, vec2(260.0, 160.0)); - let a = h.get_by_label("Parents and pages divider").rect().center(); - h.input_mut().events.push(Event::PointerMoved(a)); - h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.step(); - for dy in [20.0, 40.0, -60.0] { - h.input_mut().events.push(Event::PointerMoved(a + vec2(0.0, dy))); - h.step(); - } - h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(h.state().ui.pages_parents_height.is_some(), "{size:?}: the divider was dragged"); - } - } - - #[test] - fn parent_thumbnails_follow_panel_options() { - let parent_thumb_heights = |thumbnails: bool| { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.ui.dock_tab = "pages".into(); - app.ui.pages_panel.parent_size = IconSize::Large; - app.ui.pages_panel.parent_thumbnails = thumbnails; - let h = crate::test_window::open(app, vec2(1400.0, 900.0)); - let st = h.state().app.session.active().unwrap(); - let id = st.doc.parents[0].id; - let thumbs = super::THUMBS.lock().unwrap(); - let keys = thumbs.as_ref().map(|m| m.keys().copied().collect::>()).unwrap_or_default(); - keys.into_iter().filter(|k| k.0 == st.uid && k.2 == super::ThumbOf::Parent(id)).map(|k| k.3).collect::>() - }; - assert_eq!(parent_thumb_heights(true), vec![IconSize::Large.row_h() as u32 - 5]); - assert_eq!(parent_thumb_heights(false), Vec::::new()); - } - - #[test] - fn the_panel_menu_follows_indesigns_order() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); - let mut h = menu_harness(app, 0, 0); - assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); - // InDesign's items, top to bottom. - let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] - .iter() - .map(|l| h.get_by_label(l).rect().center().y) - .collect(); - assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); - h.get_by_label("Duplicate Spread at End of Document").click(); - h.run_steps(3); - assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); - h.get_by_label("Insert Pages…").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); - } - - #[test] - fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { - // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); - app.run("layout.pages.insert", json!({"count": 1})).unwrap(); - app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); - let mut h = harness(app); - let plus = h.get_by_label("Create new page").rect().center(); - drop_page(&mut h, 1, plus, false); - let d = h.state().session.doc().unwrap().doc.clone(); - assert_eq!(d.page_count(), 3); - let (si, pi) = d.page_loc(2).unwrap(); - assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); - let _ = h.get_by_label("Edit page size"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs new file mode 100644 index 000000000..e70c16f9b --- /dev/null +++ b/crates/ui-egui/src/panels/pages/tests.rs @@ -0,0 +1,364 @@ +use egui::{Event, Modifiers, PointerButton}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; + +use super::*; +use crate::test_window; + +#[test] +fn parent_context_menu_deletes_the_clicked_id_even_with_a_shared_prefix() { + for vertical in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.ui.pages_panel.parents_vertical = vertical; + let original = app.session.doc().unwrap().doc.parents[0].id; + let added = app.run("layout.parents.new", json!({"prefix": "A", "name": "Other"})).unwrap()["id"].as_u64().unwrap(); + let mut h = harness(app); + // Target the actual parent row in either panel layout, not a menu helper. + h.get_by_label("A-Other").click_secondary(); + h.run_steps(3); + h.get_by_label("Delete Parent").click(); + h.run_steps(3); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.parents.len(), 1); + assert_eq!(doc.parents[0].id, original); + assert!(doc.parents.iter().all(|p| p.id.0 != added)); + assert_eq!(doc.page_count(), 2, "document pages are not deleted"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc.parents.len(), 2); + } +} + +#[test] +fn double_clicking_a_parent_shows_that_parent() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for prefix in ["B", "C", "D"] { + app.run("layout.parents.new", json!({"prefix": prefix})).unwrap(); + } + app.ui.dock_tab = "pages".into(); + let mut h = test_window::open(app, vec2(1400.0, 900.0)); + let names = ["A-Parent", "B-Parent", "C-Parent", "D-Parent"]; + for name in names { + assert_ne!(h.get_by_label(name).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{name} is not selected yet"); + } + // Both clicks in one frame: the harness's event queue spaces events too far apart for a + // double click. + let at = h.get_by_label("C-Parent").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false, true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(4); + let app = &h.state().app; + assert!(app.session.active().unwrap().editing_parents); + let st = app.session.active().unwrap(); + let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); + let slot = crate::canvas::current_slot(app, &layout).unwrap(); + assert_eq!(layout.slots[slot].spread, designcraft_doc::SpreadRef::Parent(2)); + for name in names { + assert_eq!(h.get_by_label(name).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True), name == "C-Parent", "{name}"); + } + // Parent rows show a rendered thumbnail. + let id = st.doc.parents[2].id; + let rev = (st.uid, st.revision); + let thumbs = super::THUMBS.lock().unwrap(); + assert!(thumbs.as_ref().unwrap().keys().any(|k| (k.0, k.1) == rev && k.2 == super::ThumbOf::Parent(id))); +} + +fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + sized_harness(app, vec2(280.0, 520.0)) +} + +fn sized_harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h +} + +/// Release a dragged page over `at`. +fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); +} + +/// The page menu for page `abs` of spread `si`. +fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let key = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, abs, si); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); +} + +/// As in InDesign: deleting a page with objects asks first; an empty page goes at once, and +/// "Don't show again" stops the question. +#[test] +fn deleting_a_page_with_objects_asks_first() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(2).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + let count = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.page_count(); + let mut h = menu_harness(app, 2, si); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(count(&h), 5, "nothing deleted yet"); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.fields["pages"].clone()), ("deletePages", json!([2]))); + // Cancel keeps the page. + crate::dialogs::cancel(h.state_mut()); + assert_eq!(count(&h), 5); + // The whole spread: one question for both pages. + h.get_by_label("Delete Spread").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["pages"], json!([1, 2])); + crate::dialogs::cancel(h.state_mut()); + // OK deletes it. + h.get_by_label("Delete Page").click(); + h.run_steps(3); + crate::dialogs::confirm(h.state_mut()).unwrap(); + assert_eq!(count(&h), 4); + assert!(!h.state().ui.skip_delete_pages_warning); + // An empty page goes without a question. + let app = h.state_mut(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 3); + // "Don't show again". + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(1).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 300.0, x + 200.0, 400.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![1]).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShowAgain".into(), json!(true)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.skip_delete_pages_warning); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 2); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(0).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none(), "no more questions"); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 1); +} + +/// As InDesign's: the footer stays at the bottom however many pages there are, and the +/// divider between parents and pages shows more or fewer parents. +#[test] +fn the_footer_stays_put_and_the_divider_resizes_the_parents() { + use egui::{Event, Modifiers, PointerButton}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 60, "facingPages": true})).unwrap(); + for prefix in ["B", "C", "D", "E", "F", "G"] { + app.run("layout.parents.new", json!({"prefix": prefix})).unwrap(); + } + app.ui.dock_tab = "pages".into(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, 900.0)); + for label in ["60 Pages in 31 Spreads", "Create new page", "Delete selected pages"] { + assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} is on screen"); + } + // Seven parent rows: four and a half show at first; the rest scroll. + let divider_node = h.get_by_label("Parents and pages divider"); + assert_eq!(divider_node.accesskit_node().role(), egui::accesskit::Role::Splitter, "the divider is a splitter, not an unknown node"); + let divider = divider_node.rect(); + let before = divider.center().y; + assert!(h.query_by_label("G-Parent").is_none_or(|n| n.rect().top() > divider.top()), "the last parent is scrolled away"); + let (a, b) = (divider.center(), divider.center() + vec2(0.0, 80.0)); + h.input_mut().events.push(Event::PointerMoved(a)); + h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (k as f32 / 4.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let after = h.get_by_label("Parents and pages divider").rect().center().y; + assert!(after > before + 40.0, "the divider moved down: {before} → {after}"); + assert!(h.state().app.ui.pages_parents_height.is_some()); + assert!(window.contains_rect(h.get_by_label("Create new page").rect()), "the footer is still there"); +} + +/// InDesign's Panel Options: thumbnail sizes, blank pages without thumbnails, parents side by +/// side, pages on top. +#[test] +fn panel_options_size_and_arrange_the_panel() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.run("layout.parents.new", json!({"prefix": "B"})).unwrap(); + app.ui.dock_tab = "pages".into(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let page = |h: &Harness<'_, crate::test_window::Window>| h.get_by_label("Page 1").rect(); + let medium = page(&h).height(); + // The right-click menu on the panel's empty part leads to Panel Options. + let empty = h.get_by_label("Create new page").rect().center() - vec2(0.0, 60.0); + h.input_mut().events.push(egui::Event::PointerMoved(empty)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { + pos: empty, + button: egui::PointerButton::Secondary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + h.run_steps(3); + h.get_by_label("Panel Options…").click(); + h.run_steps(3); + let d = h.state().app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.id.as_str(), d.fields["pageSize"].clone(), d.fields["parentSize"].clone(), d.fields["layout"].clone()), + ("pagesPanelOptions", json!("medium"), json!("small"), json!("parentsOnTop")) + ); + // Jumbo pages, parents side by side, pages on top. + let set = |h: &mut Harness<'_, crate::test_window::Window>, k: &str, v: serde_json::Value| { + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + set(&mut h, "pageSize", json!("jumbo")); + set(&mut h, "parentsVertical", json!(false)); + set(&mut h, "layout", json!("pagesOnTop")); + crate::dialogs::confirm(&mut h.state_mut().app).unwrap(); + h.run_steps(4); + assert!(page(&h).height() > 2.5 * medium, "Jumbo: {} vs {medium}", page(&h).height()); + let o = &h.state().app.ui.pages_panel; + assert_eq!((o.page_size, o.parents_vertical, o.pages_on_top), (IconSize::Jumbo, false, true)); + // Pages on top: the parents come after the pages; side by side: [None] and A sit in one row. + let (none, a) = (h.get_by_label("[None]").rect(), h.get_by_label("A-Parent").rect()); + assert!(none.top() > page(&h).top(), "parents below the pages"); + assert!((none.top() - a.top()).abs() < 1.0 && a.left() > none.right(), "side by side"); + // Extra Small: no thumbnails, just pages. + crate::panels::pages::open_panel_options(&mut h.state_mut().app); + set(&mut h, "pageSize", json!("extraSmall")); + crate::dialogs::confirm(&mut h.state_mut().app).unwrap(); + h.run_steps(4); + assert!(page(&h).height() < 0.5 * medium); +} + +/// Panel Options' parent size sets the height of the parents' rendered thumbnails (cached per +/// parent and size), and Show Thumbnails off leaves them blank. +/// Parent rows taller than the room left for the parents (Extra Large or Jumbo in a short +/// panel) draw and drag without a panic. +#[test] +fn large_parent_rows_in_a_short_panel_do_not_panic() { + use egui::{Event, Modifiers, PointerButton}; + for size in [IconSize::ExtraLarge, IconSize::Jumbo] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4})).unwrap(); + app.ui.pages_panel.parent_size = size; + let mut h = sized_harness(app, vec2(260.0, 160.0)); + let a = h.get_by_label("Parents and pages divider").rect().center(); + h.input_mut().events.push(Event::PointerMoved(a)); + h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for dy in [20.0, 40.0, -60.0] { + h.input_mut().events.push(Event::PointerMoved(a + vec2(0.0, dy))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.pages_parents_height.is_some(), "{size:?}: the divider was dragged"); + } +} + +#[test] +fn parent_thumbnails_follow_panel_options() { + let parent_thumb_heights = |thumbnails: bool| { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dock_tab = "pages".into(); + app.ui.pages_panel.parent_size = IconSize::Large; + app.ui.pages_panel.parent_thumbnails = thumbnails; + let h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let st = h.state().app.session.active().unwrap(); + let id = st.doc.parents[0].id; + let thumbs = super::THUMBS.lock().unwrap(); + let keys = thumbs.as_ref().map(|m| m.keys().copied().collect::>()).unwrap_or_default(); + keys.into_iter().filter(|k| k.0 == st.uid && k.2 == super::ThumbOf::Parent(id)).map(|k| k.3).collect::>() + }; + assert_eq!(parent_thumb_heights(true), vec![IconSize::Large.row_h() as u32 - 5]); + assert_eq!(parent_thumb_heights(false), Vec::::new()); +} + +#[test] +fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); + // InDesign's items, top to bottom. + let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] + .iter() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); +} + +#[test] +fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); +}