From b50eb265af6e5851cf2d8aca8c62d9f8b80a7089 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 20:12:36 +0200 Subject: [PATCH 1/4] Pages panel: parents and pages scroll on their own, a divider resizes them, the footer stays at the bottom Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dock.rs | 25 ++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panel_docking.rs | 4 + crates/ui-egui/src/panels/pages.rs | 269 ++++++++++++++++++---------- 5 files changed, 205 insertions(+), 97 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..1673e0c5 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: The Pages panel is laid out as InDesign's: parents and pages scroll on their own, a divider between them shows more or fewer parents, and the footer (page count, Edit page size, Create new page, Delete) stays at the bottom. It scrolled away with the pages. - **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. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 6890a6a2..3ab13bf4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -106,8 +106,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::panel_docking::panel_menu(app, ui, &tab); }); egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { + // The Pages panel scrolls its parts itself and keeps its footer at the bottom. + if app.ui.dock_tab == "pages" { + panels::pages::show(app, ui); + return; + } egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { - "pages" => panels::pages::show(app, ui), "layers" => panels::layers::show(app, ui), _ => panels::properties::show(app, ui), }); @@ -234,7 +238,7 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { return; } egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(screen.height() - 220.0).show(ui, |ui| panel_body(app, ui, &id)); + scrolled_body(app, ui, &id, screen.height() - 220.0); }); }); }); @@ -243,6 +247,21 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { } } +/// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its +/// parts itself and keeps its footer at the bottom, so it gets the height instead. +fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { + if scrolls_itself(id) { + ui.allocate_ui(vec2(ui.available_width(), max_h.clamp(160.0, 560.0)), |ui| panel_body(app, ui, id)); + } else { + egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| panel_body(app, ui, id)); + } +} + +/// Panels that lay out their own scrolling parts (the Pages panel: parents, pages, footer). +pub fn scrolls_itself(id: &str) -> bool { + id == "pages" +} + /// The contents of an icon-column panel. pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { match id { @@ -351,7 +370,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { } dock_glyph(ui.painter(), dock_r, t.text_dim); egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(ctx.content_rect().height() - 160.0).show(ui, |ui| panel_body(app, ui, &id)); + scrolled_body(app, ui, &id, ctx.content_rect().height() - 160.0); }); }); // Remember where it was left. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..49b851c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,8 @@ pub struct UiState { pub flattener_preview: bool, /// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown). pub hidden_panels: u8, + /// The Pages panel's parents part, as its divider was dragged (None: a few rows). + pub pages_parents_height: Option, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, /// View › Proof Colors and View › Proof Setup. @@ -339,6 +341,7 @@ impl Default for UiState { tag_markers: false, flattener_preview: false, hidden_panels: 0, + pages_parents_height: None, color_settings: None, proof_colors: false, proof_setup: Default::default(), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 05fc1150..ca66be4f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -480,6 +480,10 @@ struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { + if crate::dock::scrolls_itself(id) { + crate::dock::panel_body(self.0, ui, id); + return; + } egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 2d6d56da..adece496 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -51,6 +51,12 @@ enum PageDrag { Parent(Option), } +/// The footer: page count and buttons, always at the panel's bottom. +const FOOTER_H: f32 = 28.0; +/// The draggable rule between the parents and the pages. +const DIVIDER_H: f32 = 9.0; +/// Parent rows shown at first; the divider shows more. +const PARENTS_DEFAULT_ROWS: f32 = 4.5; /// Selected-page tint (multiplied over the thumbnail). const SELECTED_TINT: Color32 = Color32::from_rgb(0x88, 0xc7, 0xfb); /// Page-number badge of the selected page. @@ -86,111 +92,150 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); } - for (label, prefix, n) in rows { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); - let active = editing_parents && prefix.is_some(); - if active { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - ui.painter().text(r.left_center() + vec2(3.0, 0.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), t.text_strong); - blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t); - resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); - if resp.double_clicked() && prefix.is_some() { - let _ = app.run("layout.parents.edit", json!({"on": true})); - app.views.clear(); - } - if resp.dragged() { - drag_ghost(ui, &label, &t); - } + // As in InDesign: parents above, pages below, each scrolling on its own; a divider between + // them resizes the parents' part; the footer stays at the bottom. + let total = { + let h = ui.available_height(); + if h.is_finite() && h < 4000.0 { h } else { 480.0 } } - ui.add_space(4.0); - let r = ui.available_rect_before_wrap(); - ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(r.min.x - 10.0, r.min.y), vec2(r.width() + 20.0, 1.5)), 0.0, t.section_divider); - ui.add_space(12.0); + .max(160.0); + let natural = rows.len() as f32 * ROW_H; + let parents_max = (total - FOOTER_H - DIVIDER_H - 2.0 * ROW_H).max(ROW_H); + let parents_h = + app.ui.pages_parents_height.unwrap_or(natural.min(PARENTS_DEFAULT_ROWS * ROW_H)).clamp(ROW_H.min(natural), parents_max).min(natural); + egui::ScrollArea::vertical().id_salt("pages_parents").max_height(parents_h).min_scrolled_height(parents_h).auto_shrink([false, true]).show( + ui, + |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for (label, prefix, n) in rows { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + let active = editing_parents && prefix.is_some(); + if active { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + ui.painter().text( + r.left_center() + vec2(3.0, 0.0), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(11.5), + t.text_strong, + ); + blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t); + resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); + if resp.double_clicked() && prefix.is_some() { + let _ = app.run("layout.parents.edit", json!({"on": true})); + app.views.clear(); + } + if resp.dragged() { + drag_ghost(ui, &label, &t); + } + } + }, + ); + // The divider: drag it to show more or fewer parents. + let (dr, dresp) = ui.allocate_exact_size(vec2(ui.available_width(), DIVIDER_H), Sense::drag()); + let line = egui::Rect::from_center_size(dr.center(), vec2(dr.width() + 20.0, 1.5)); + ui.painter().rect_filled(line, 0.0, if dresp.hovered() || dresp.dragged() { t.text_dim } else { t.section_divider }); + let dresp = dresp.on_hover_cursor(egui::CursorIcon::ResizeVertical); + if dresp.dragged() { + app.ui.pages_parents_height = Some((parents_h + dresp.drag_delta().y).clamp(ROW_H.min(natural), parents_max)); + } + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Parents and pages divider")); // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); - for (si, sp) in doc.spreads.iter().enumerate() { - let first = doc.first_page_of_spread(si); - // One scale for the whole document (pages of their own size show in proportion): the - // document's page size at the usual height, unless a taller page would pass 1.8 times it. - // Each row is as tall as its spread's tallest page. - let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); - let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); - let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); - let top = row.min.y + 8.0; - // Marker above the current spread. - if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], t.icon, Stroke::NONE)); - } - for (pi, p) in sp.pages.iter().enumerate() { - let abs = first + pi; - let x0 = row.min.x + center + (p.x as f32 - spine) * scale; - let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); - let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); - let selected = abs == cur && !editing_parents; - let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; - if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) { - ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); - } else { - ui.painter().rect_filled(pr, 0.0, tint); - } - ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); - // Parent prefix in the outer top corner (black on white). - if let Some(pid) = p.parent - && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) - { - let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default(); - let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK); - let w = g.size().x + 2.0; - let br = if p.side == designcraft_doc::PageSide::Left { - egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0)) - } else { - egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0)) - }; - ui.painter().rect_filled(br, 0.0, Color32::WHITE); - ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK); - } - // Drag and drop: pages reorder, parents apply. - resp.dnd_set_drag_payload(PageDrag::Page(abs)); - if resp.dragged() { - drag_ghost(ui, &app.session.page_label(abs), &t); - } - if dragging && resp.contains_pointer() { - ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); - } - if let Some(payload) = resp.dnd_release_payload::() { - drop = Some(((*payload).clone(), abs)); - } - if resp.clicked() || resp.double_clicked() { - if editing_parents { - let _ = app.run("layout.parents.edit", json!({"on": false})); - app.views.clear(); + let pages_h = (ui.available_height().min(total) - FOOTER_H).max(ROW_H); + egui::ScrollArea::vertical().id_salt("pages_spreads").max_height(pages_h).min_scrolled_height(pages_h).auto_shrink([false, false]).show( + ui, + |ui| { + ui.add_space(4.0); + for (si, sp) in doc.spreads.iter().enumerate() { + let first = doc.first_page_of_spread(si); + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); + let spine = sp.spine_x() as f32; + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); + let top = row.min.y + 8.0; + // Marker above the current spread. + if cur_spread == Some(si) && !editing_parents { + let c = egui::pos2(row.min.x + center, row.min.y + 3.5); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); + } + for (pi, p) in sp.pages.iter().enumerate() { + let abs = first + pi; + let x0 = row.min.x + center + (p.x as f32 - spine) * scale; + let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); + let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); + let selected = abs == cur && !editing_parents; + let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; + if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) { + ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); + } else { + ui.painter().rect_filled(pr, 0.0, tint); + } + ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + // Parent prefix in the outer top corner (black on white). + if let Some(pid) = p.parent + && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) + { + let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default(); + let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK); + let w = g.size().x + 2.0; + let br = if p.side == designcraft_doc::PageSide::Left { + egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0)) + } else { + egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0)) + }; + ui.painter().rect_filled(br, 0.0, Color32::WHITE); + ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK); + } + // Drag and drop: pages reorder, parents apply. + resp.dnd_set_drag_payload(PageDrag::Page(abs)); + if resp.dragged() { + drag_ghost(ui, &app.session.page_label(abs), &t); + } + if dragging && resp.contains_pointer() { + ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); + } + if let Some(payload) = resp.dnd_release_payload::() { + drop = Some(((*payload).clone(), abs)); + } + if resp.clicked() || resp.double_clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + app.views.clear(); + } + crate::canvas::go_to_page(app, abs); + } + resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); + // Page number under the page: white on a blue badge when selected. + let name = app.session.page_label(abs); + let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(pr.center().x, pr.max.y + 11.0); + if selected { + let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - crate::canvas::go_to_page(app, abs); } - resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); - } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); - } - } - ui.add_space(6.0); + }, + ); ui.spacing_mut().item_spacing.y = 4.0; ui.horizontal(|ui| { + ui.set_min_height(FOOTER_H - 4.0); crate::rtl::label( ui, egui::RichText::new( @@ -438,6 +483,42 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); } + /// 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 = h.get_by_label("Parents and pages divider").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"); + } + #[test] fn the_panel_menu_follows_indesigns_order() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From ff71790737bbe1a2a6220bcad43b3868416b9f16 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 20:24:57 +0200 Subject: [PATCH 2/4] Pages panel: Panel Options (sizes, thumbnails, parents side by side, pages on top, resize), as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 50 ++- crates/ui-egui/src/i18n.rs | 21 ++ crates/ui-egui/src/i18n/ar.rs | 18 + crates/ui-egui/src/i18n/de.rs | 18 + crates/ui-egui/src/i18n/it.rs | 18 + crates/ui-egui/src/i18n/ja.rs | 18 + crates/ui-egui/src/i18n/ka.rs | 18 + crates/ui-egui/src/i18n/pt_br.rs | 18 + crates/ui-egui/src/i18n/uk.rs | 18 + crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/panels/pages.rs | 576 ++++++++++++++++++++++------- 12 files changed, 651 insertions(+), 131 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1673e0c5..d1b15c88 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: Pages panel › Panel Options…, as InDesign's: page and parent sizes from Extra Small to Jumbo, Show Thumbnails, parents shown vertically or side by side, Pages or Parents on Top, and how the two parts share a resize; in the panel menu, the page menu and the right-click menu on the panel's empty part. - 2026-10-15: The Pages panel is laid out as InDesign's: parents and pages scroll on their own, a divider between them shows more or fewer parents, and the footer (page count, Edit page size, Create new page, Delete) stays at the bottom. It scrolled away with the pages. - **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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..a5584768 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1512,6 +1512,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "pagesPanelOptions" => crate::i18n::tr(&app.ui.language, "Panel Options"), "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), @@ -1537,7 +1538,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" => 380.0, + "closeDocument" | "updateLink" | "pagesPanelOptions" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -2023,6 +2024,42 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .replace("{}", &d.s("title")); crate::rtl::label(ui, text); } + "pagesPanelOptions" => { + // As InDesign's: Pages, Parents, Panel Layout. + use crate::panels::pages::{IconSize, PanelResize}; + let lang = app.ui.language.clone(); + let sizes: Vec<(&str, &str)> = IconSize::ALL.iter().map(|s| (s.key(), crate::i18n::tr(&lang, s.label()))).collect(); + let group = |ui: &mut egui::Ui, title: &str| { + ui.add_space(4.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).strong()); + }; + group(ui, "Pages"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Size:")); + combo(ui, &mut d, "pageSize", &sizes); + }); + // Extra Small pages have no room for a thumbnail. + ui.add_enabled_ui(d.s("pageSize") != "extraSmall", |ui| { + check(ui, &mut d, "pageThumbnails", crate::i18n::tr(&lang, "Show Thumbnails")) + }); + group(ui, "Parents"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Size:")); + combo(ui, &mut d, "parentSize", &sizes); + }); + check(ui, &mut d, "parentsVertical", crate::i18n::tr(&lang, "Show Vertically")); + group(ui, "Panel Layout"); + for (v, label) in [("pagesOnTop", "Pages on Top"), ("parentsOnTop", "Parents on Top")] { + if ui.radio(d.s("layout") == v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() { + d.fields.insert("layout".into(), json!(v)); + } + } + let resizes: Vec<(&str, &str)> = PanelResize::ALL.iter().map(|r| (r.key(), crate::i18n::tr(&lang, r.label()))).collect(); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Resize:")); + combo(ui, &mut d, "resize", &resizes); + }); + } "updateLink" => { let text = crate::i18n::tr( &app.ui.language, @@ -2636,6 +2673,17 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "pagesPanelOptions" => { + use crate::panels::pages::{IconSize, PanelResize}; + let o = &mut app.ui.pages_panel; + o.page_size = IconSize::from_key(&d.s("pageSize")).unwrap_or(o.page_size); + o.page_thumbnails = d.b("pageThumbnails"); + o.parent_size = IconSize::from_key(&d.s("parentSize")).unwrap_or(o.parent_size); + o.parents_vertical = d.b("parentsVertical"); + o.pages_on_top = d.s("layout") == "pagesOnTop"; + o.resize = PanelResize::from_key(&d.s("resize")).unwrap_or(o.resize); + Ok(Value::Null) + } "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e28..9577f497 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,27 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Panel Options", ["Bedienfeldoptionen", "Options du panneau", "Opciones de panel", "パネルオプション", "面板选项"]), + ("Panel Options…", ["Bedienfeldoptionen…", "Options du panneau…", "Opciones de panel…", "パネルオプション…", "面板选项…"]), + ("Show Thumbnails", ["Miniaturen anzeigen", "Afficher les vignettes", "Mostrar miniaturas", "サムネールを表示", "显示缩览图"]), + ("Show Vertically", ["Vertikal anzeigen", "Afficher verticalement", "Mostrar verticalmente", "縦に表示", "垂直显示"]), + ("Panel Layout", ["Bedienfeldlayout", "Disposition du panneau", "Diseño del panel", "パネルレイアウト", "面板版面"]), + ("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]), + ( + "Parents on Top", + ["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"], + ), + ("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]), + ("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]), + ("Small", ["Klein", "Petite", "Pequeño", "小", "小"]), + ("Medium", ["Mittel", "Moyenne", "Mediano", "中", "中"]), + ("Large", ["Groß", "Grande", "Grande", "大", "大"]), + ("Extra Large", ["Sehr groß", "Très grande", "Extra grande", "特大", "特大"]), + ("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]), + ("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]), + ("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]), + ("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), + ("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7ac..afd80dd2 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Panel Options", "خيارات اللوحة"), + ("Panel Options…", "خيارات اللوحة…"), + ("Show Thumbnails", "إظهار الصور المصغرة"), + ("Show Vertically", "إظهار عموديًا"), + ("Panel Layout", "تخطيط اللوحة"), + ("Pages on Top", "الصفحات في الأعلى"), + ("Parents on Top", "الصفحات الرئيسية في الأعلى"), + ("Resize:", "تغيير الحجم:"), + ("Extra Small", "صغير جدًا"), + ("Small", "صغير"), + ("Medium", "متوسط"), + ("Large", "كبير"), + ("Extra Large", "كبير جدًا"), + ("Jumbo", "ضخم"), + ("Proportional", "تناسبي"), + ("Pages Fixed", "الصفحات ثابتة"), + ("Parents Fixed", "الصفحات الرئيسية ثابتة"), + ("Parents", "الصفحات الرئيسية"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b..87910e7c 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Panel Options", "Bedienfeldoptionen"), + ("Panel Options…", "Bedienfeldoptionen…"), + ("Show Thumbnails", "Miniaturen anzeigen"), + ("Show Vertically", "Vertikal anzeigen"), + ("Panel Layout", "Bedienfeldlayout"), + ("Pages on Top", "Seiten oben"), + ("Parents on Top", "Übergeordnete Seiten oben"), + ("Resize:", "Größe ändern:"), + ("Extra Small", "Sehr klein"), + ("Small", "Klein"), + ("Medium", "Mittel"), + ("Large", "Groß"), + ("Extra Large", "Sehr groß"), + ("Jumbo", "Riesig"), + ("Proportional", "Proportional"), + ("Pages Fixed", "Seiten fixiert"), + ("Parents Fixed", "Übergeordnete fixiert"), + ("Parents", "Übergeordnete Seiten"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9..6845e97a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Panel Options", "Opzioni pannello"), + ("Panel Options…", "Opzioni pannello…"), + ("Show Thumbnails", "Mostra miniature"), + ("Show Vertically", "Mostra in verticale"), + ("Panel Layout", "Layout pannello"), + ("Pages on Top", "Pagine in alto"), + ("Parents on Top", "Pagine mastro in alto"), + ("Resize:", "Ridimensiona:"), + ("Extra Small", "Molto piccola"), + ("Small", "Piccola"), + ("Medium", "Media"), + ("Large", "Grande"), + ("Extra Large", "Molto grande"), + ("Jumbo", "Gigante"), + ("Proportional", "Proporzionale"), + ("Pages Fixed", "Pagine fisse"), + ("Parents Fixed", "Pagine mastro fisse"), + ("Parents", "Pagine mastro"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e..ac5cc4ab 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Panel Options", "パネルオプション"), + ("Panel Options…", "パネルオプション…"), + ("Show Thumbnails", "サムネールを表示"), + ("Show Vertically", "縦に表示"), + ("Panel Layout", "パネルレイアウト"), + ("Pages on Top", "ページを上に表示"), + ("Parents on Top", "親ページを上に表示"), + ("Resize:", "サイズ変更:"), + ("Extra Small", "極小"), + ("Small", "小"), + ("Medium", "中"), + ("Large", "大"), + ("Extra Large", "特大"), + ("Jumbo", "最大"), + ("Proportional", "比例"), + ("Pages Fixed", "ページを固定"), + ("Parents Fixed", "親ページを固定"), + ("Parents", "親ページ"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d..1926691b 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Panel Options", "პანელის პარამეტრები"), + ("Panel Options…", "პანელის პარამეტრები…"), + ("Show Thumbnails", "მინიატურების ჩვენება"), + ("Show Vertically", "ვერტიკალურად ჩვენება"), + ("Panel Layout", "პანელის განლაგება"), + ("Pages on Top", "გვერდები ზემოთ"), + ("Parents on Top", "მშობლები ზემოთ"), + ("Resize:", "ზომის შეცვლა:"), + ("Extra Small", "ძალიან პატარა"), + ("Small", "პატარა"), + ("Medium", "საშუალო"), + ("Large", "დიდი"), + ("Extra Large", "ძალიან დიდი"), + ("Jumbo", "უზარმაზარი"), + ("Proportional", "პროპორციული"), + ("Pages Fixed", "გვერდები ფიქსირებული"), + ("Parents Fixed", "მშობლები ფიქსირებული"), + ("Parents", "მშობელი გვერდები"), ("Reverse Path", "ტრაექტორიის შებრუნება"), ("Set Anchor", "საყრდენი წერტილის დაყენება"), ("Clear", "გასუფთავება"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0675a2f6..483115fa 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Panel Options", "Opções do painel"), + ("Panel Options…", "Opções do painel…"), + ("Show Thumbnails", "Mostrar miniaturas"), + ("Show Vertically", "Mostrar verticalmente"), + ("Panel Layout", "Layout do painel"), + ("Pages on Top", "Páginas na parte superior"), + ("Parents on Top", "Páginas-pai na parte superior"), + ("Resize:", "Redimensionar:"), + ("Extra Small", "Extra pequeno"), + ("Small", "Pequeno"), + ("Medium", "Médio"), + ("Large", "Grande"), + ("Extra Large", "Extra grande"), + ("Jumbo", "Gigante"), + ("Proportional", "Proporcional"), + ("Pages Fixed", "Páginas fixas"), + ("Parents Fixed", "Páginas-pai fixas"), + ("Parents", "Páginas-pai"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f8..3f2a21cd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Panel Options", "Параметри панелі"), + ("Panel Options…", "Параметри панелі…"), + ("Show Thumbnails", "Показувати мініатюри"), + ("Show Vertically", "Показувати вертикально"), + ("Panel Layout", "Компонування панелі"), + ("Pages on Top", "Сторінки зверху"), + ("Parents on Top", "Батьківські зверху"), + ("Resize:", "Зміна розміру:"), + ("Extra Small", "Дуже малий"), + ("Small", "Малий"), + ("Medium", "Середній"), + ("Large", "Великий"), + ("Extra Large", "Дуже великий"), + ("Jumbo", "Величезний"), + ("Proportional", "Пропорційно"), + ("Pages Fixed", "Сторінки фіксовані"), + ("Parents Fixed", "Батьківські фіксовані"), + ("Parents", "Батьківські сторінки"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49b851c7..57b9a67d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,8 +195,11 @@ pub struct UiState { pub flattener_preview: bool, /// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown). pub hidden_panels: u8, - /// The Pages panel's parents part, as its divider was dragged (None: a few rows). - pub pages_parents_height: Option, + /// The Pages panel's parents part as its divider was last dragged, and the height the two + /// parts shared then (None: a few rows). + pub pages_parents_height: Option<[f32; 2]>, + /// The Pages panel's Panel Options. + pub pages_panel: panels::pages::PanelOptions, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, /// View › Proof Colors and View › Proof Setup. @@ -342,6 +345,7 @@ impl Default for UiState { flattener_preview: false, hidden_panels: 0, pages_parents_height: None, + pages_panel: Default::default(), color_settings: None, proof_colors: false, proof_setup: Default::default(), diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index adece496..56200623 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -9,13 +9,14 @@ use serde_json::json; use crate::theme::Tokens; use crate::{DesignApp, icons}; -type ThumbKey = (u64, u64, usize); +/// (document uid, revision, page, thumbnail height). +type ThumbKey = (u64, u64, usize, u32); static THUMBS: Mutex>> = Mutex::new(None); fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option { let st = app.session.active()?; - let key = (st.uid, st.revision, abs); + let key = (st.uid, st.revision, abs, h.round() as u32); let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner()); let map = g.get_or_insert_with(HashMap::new); if let Some(t) = map.get(&key) { @@ -34,7 +35,7 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }, )?; let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = crate::widgets::load_texture(ctx, format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); + let tex = crate::widgets::load_texture(ctx, format!("thumb{abs}_{}", h.round()), ci, egui::TextureOptions::LINEAR); // Drop thumbnails of older revisions. let (uid, rev) = (st.uid, st.revision); map.retain(|k, _| !(k.0 == uid && k.1 != rev)); @@ -42,6 +43,118 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option Some(tex) } +/// Thumbnail sizes of the Pages panel (Panel Options › Size), InDesign's six. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IconSize { + ExtraSmall, + #[default] + Small, + Medium, + Large, + ExtraLarge, + Jumbo, +} + +impl IconSize { + pub const ALL: [IconSize; 6] = [IconSize::ExtraSmall, IconSize::Small, IconSize::Medium, IconSize::Large, IconSize::ExtraLarge, IconSize::Jumbo]; + + pub fn label(self) -> &'static str { + match self { + IconSize::ExtraSmall => "Extra Small", + IconSize::Small => "Small", + IconSize::Medium => "Medium", + IconSize::Large => "Large", + IconSize::ExtraLarge => "Extra Large", + IconSize::Jumbo => "Jumbo", + } + } + + pub fn key(self) -> &'static str { + match self { + IconSize::ExtraSmall => "extraSmall", + IconSize::Small => "small", + IconSize::Medium => "medium", + IconSize::Large => "large", + IconSize::ExtraLarge => "extraLarge", + IconSize::Jumbo => "jumbo", + } + } + + pub fn from_key(k: &str) -> Option { + IconSize::ALL.into_iter().find(|s| s.key() == k) + } + + /// Height of a page thumbnail. + fn page_h(self) -> f32 { + [14.0, 24.0, THUMB_H, 55.0, 80.0, 120.0][self as usize] + } + + /// Height of a parent row. + fn row_h(self) -> f32 { + [18.0, ROW_H, 40.0, 56.0, 76.0, 110.0][self as usize] + } +} + +/// How the Pages panel's parts share a change of the panel's height (Panel Options › Resize). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PanelResize { + Proportional, + PagesFixed, + #[default] + ParentsFixed, +} + +impl PanelResize { + pub const ALL: [PanelResize; 3] = [PanelResize::Proportional, PanelResize::PagesFixed, PanelResize::ParentsFixed]; + + pub fn label(self) -> &'static str { + match self { + PanelResize::Proportional => "Proportional", + PanelResize::PagesFixed => "Pages Fixed", + PanelResize::ParentsFixed => "Parents Fixed", + } + } + + pub fn key(self) -> &'static str { + match self { + PanelResize::Proportional => "proportional", + PanelResize::PagesFixed => "pagesFixed", + PanelResize::ParentsFixed => "parentsFixed", + } + } + + pub fn from_key(k: &str) -> Option { + PanelResize::ALL.into_iter().find(|s| s.key() == k) + } +} + +/// The Pages panel's Panel Options, as InDesign's (kept with the UI settings). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PanelOptions { + pub page_size: IconSize, + pub page_thumbnails: bool, + pub parent_size: IconSize, + pub parents_vertical: bool, + pub pages_on_top: bool, + pub resize: PanelResize, +} + +impl Default for PanelOptions { + fn default() -> Self { + PanelOptions { + page_size: IconSize::Medium, + page_thumbnails: true, + parent_size: IconSize::Small, + parents_vertical: true, + pages_on_top: false, + resize: PanelResize::ParentsFixed, + } + } +} + /// What is being dragged in the Pages panel. #[derive(Clone, Debug, PartialEq)] enum PageDrag { @@ -67,8 +180,7 @@ const THUMB_H: f32 = 37.0; const ROW_H: f32 = 29.0; /// Paint a blank page icon of `n` pages (parents list), right-aligned at `right`. -fn blank_spread(p: &egui::Painter, right: egui::Pos2, n: usize, aspect: f32, t: &Tokens) { - let h = 24.0; +fn blank_spread(p: &egui::Painter, right: egui::Pos2, n: usize, aspect: f32, h: f32, t: &Tokens) { let w = h * aspect; let n = n.max(1); for k in 0..n { @@ -92,147 +204,263 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); } - // As in InDesign: parents above, pages below, each scrolling on its own; a divider between - // them resizes the parents' part; the footer stays at the bottom. + // As in InDesign: parents and pages each scroll on their own (parents on top unless Panel + // Options says pages), a divider between them resizes them, the footer stays at the bottom. + let opts = app.ui.pages_panel.clone(); let total = { let h = ui.available_height(); if h.is_finite() && h < 4000.0 { h } else { 480.0 } } .max(160.0); - let natural = rows.len() as f32 * ROW_H; - let parents_max = (total - FOOTER_H - DIVIDER_H - 2.0 * ROW_H).max(ROW_H); - let parents_h = - app.ui.pages_parents_height.unwrap_or(natural.min(PARENTS_DEFAULT_ROWS * ROW_H)).clamp(ROW_H.min(natural), parents_max).min(natural); - egui::ScrollArea::vertical().id_salt("pages_parents").max_height(parents_h).min_scrolled_height(parents_h).auto_shrink([false, true]).show( - ui, - |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - for (label, prefix, n) in rows { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); - let active = editing_parents && prefix.is_some(); - if active { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - ui.painter().text( - r.left_center() + vec2(3.0, 0.0), - egui::Align2::LEFT_CENTER, - &label, - egui::FontId::proportional(11.5), - t.text_strong, - ); - blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t); - resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); - if resp.double_clicked() && prefix.is_some() { - let _ = app.run("layout.parents.edit", json!({"on": true})); - app.views.clear(); - } - if resp.dragged() { - drag_ghost(ui, &label, &t); - } + let row_h = opts.parent_size.row_h(); + let icon_h = row_h - 5.0; + // Parents side by side (Show Vertically off): tiles of the icon and the prefix, wrapping. + let tile_w = |n: usize| (icon_h * aspect * n.max(1) as f32 + 16.0).max(44.0); + let natural = if opts.parents_vertical { + rows.len() as f32 * row_h + } else { + let width = (ui.available_width() - 4.0).max(1.0); + let (mut lines, mut x) = (1.0_f32, 0.0_f32); + for (_, _, n) in &rows { + let w = tile_w(*n) + 6.0; + if x > 0.0 && x + w > width { + lines += 1.0; + x = 0.0; } + x += w; + } + lines * (icon_h + 22.0) + }; + let space = (total - FOOTER_H - DIVIDER_H).max(2.0 * ROW_H); + let parents_max = (space - 2.0 * ROW_H).max(ROW_H); + let parents_min = row_h.min(natural); + // The divider's place as last dragged, and how a change of the panel's height is shared. + let parents_h = match app.ui.pages_parents_height { + None => natural.min(PARENTS_DEFAULT_ROWS * ROW_H), + Some([h, at]) => match opts.resize { + PanelResize::ParentsFixed => h, + PanelResize::PagesFixed => h + (space - at), + PanelResize::Proportional => h * space / at.max(1.0), }, - ); - // The divider: drag it to show more or fewer parents. - let (dr, dresp) = ui.allocate_exact_size(vec2(ui.available_width(), DIVIDER_H), Sense::drag()); - let line = egui::Rect::from_center_size(dr.center(), vec2(dr.width() + 20.0, 1.5)); - ui.painter().rect_filled(line, 0.0, if dresp.hovered() || dresp.dragged() { t.text_dim } else { t.section_divider }); - let dresp = dresp.on_hover_cursor(egui::CursorIcon::ResizeVertical); - if dresp.dragged() { - app.ui.pages_parents_height = Some((parents_h + dresp.drag_delta().y).clamp(ROW_H.min(natural), parents_max)); } - dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Parents and pages divider")); + .clamp(parents_min, parents_max) + .min(natural); + let pages_h = (space - parents_h).max(ROW_H); + let parents_part = |app: &mut DesignApp, ui: &mut egui::Ui| { + egui::ScrollArea::vertical().id_salt("pages_parents").max_height(parents_h).min_scrolled_height(parents_h).auto_shrink([false, true]).show( + ui, + |ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 0.0); + let each = |app: &mut DesignApp, ui: &mut egui::Ui, label: &str, prefix: &Option, n: usize| { + let (r, resp) = if opts.parents_vertical { + ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()) + } else { + ui.allocate_exact_size(vec2(tile_w(n), icon_h + 22.0), Sense::click_and_drag()) + }; + let active = editing_parents && prefix.is_some(); + if active { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + if opts.parents_vertical { + ui.painter().text( + r.left_center() + vec2(3.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + t.text_strong, + ); + blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, icon_h, &t); + } else { + let w = icon_h * aspect * n.max(1) as f32; + blank_spread(ui.painter(), egui::pos2(r.center().x + w / 2.0, r.min.y + 3.0 + icon_h / 2.0), n, aspect, icon_h, &t); + let short = prefix.clone().unwrap_or_else(|| label.to_string()); + ui.painter().text( + egui::pos2(r.center().x, r.max.y - 9.0), + egui::Align2::CENTER_CENTER, + short, + egui::FontId::proportional(11.0), + t.text_strong, + ); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let resp = if opts.parents_vertical { resp } else { resp.on_hover_text(label) }; + resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); + if resp.double_clicked() && prefix.is_some() { + let _ = app.run("layout.parents.edit", json!({"on": true})); + app.views.clear(); + } + if resp.dragged() { + drag_ghost(ui, label, &t); + } + }; + if opts.parents_vertical { + for (label, prefix, n) in &rows { + each(app, ui, label, prefix, *n); + } + } else { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 0.0); + for (label, prefix, n) in &rows { + each(app, ui, label, prefix, *n); + } + }); + } + }, + ); + }; // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); - let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); - let pages_h = (ui.available_height().min(total) - FOOTER_H).max(ROW_H); - egui::ScrollArea::vertical().id_salt("pages_spreads").max_height(pages_h).min_scrolled_height(pages_h).auto_shrink([false, false]).show( - ui, - |ui| { - ui.add_space(4.0); - for (si, sp) in doc.spreads.iter().enumerate() { - let first = doc.first_page_of_spread(si); - // One scale for the whole document (pages of their own size show in proportion): the - // document's page size at the usual height, unless a taller page would pass 1.8 times it. - // Each row is as tall as its spread's tallest page. - let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); - let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); - let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); - let top = row.min.y + 8.0; - // Marker above the current spread. - if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon( - vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], - t.icon, - Stroke::NONE, - )); - } - for (pi, p) in sp.pages.iter().enumerate() { - let abs = first + pi; - let x0 = row.min.x + center + (p.x as f32 - spine) * scale; - let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); - let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); - let selected = abs == cur && !editing_parents; - let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; - if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) { - ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); - } else { - ui.painter().rect_filled(pr, 0.0, tint); + let th = opts.page_size.page_h(); + // Extra Small has no room for a thumbnail; Show Thumbnails off draws blank pages. + let thumbnails = opts.page_thumbnails && opts.page_size != IconSize::ExtraSmall; + let mut pages_part = |app: &mut DesignApp, ui: &mut egui::Ui| { + let center = ui.available_width() / 2.0; + egui::ScrollArea::vertical().id_salt("pages_spreads").max_height(pages_h).min_scrolled_height(pages_h).auto_shrink([false, false]).show( + ui, + |ui| { + ui.add_space(4.0); + for (si, sp) in doc.spreads.iter().enumerate() { + let first = doc.first_page_of_spread(si); + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the panel's size, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (th / doc.settings.page_height.max(1.0) as f32).min(1.8 * th / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(th * 0.25); + let spine = sp.spine_x() as f32; + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); + let top = row.min.y + 8.0; + // Marker above the current spread. + if cur_spread == Some(si) && !editing_parents { + let c = egui::pos2(row.min.x + center, row.min.y + 3.5); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); } - ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); - // Parent prefix in the outer top corner (black on white). - if let Some(pid) = p.parent - && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) - { - let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default(); - let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK); - let w = g.size().x + 2.0; - let br = if p.side == designcraft_doc::PageSide::Left { - egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0)) - } else { - egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0)) - }; - ui.painter().rect_filled(br, 0.0, Color32::WHITE); - ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK); - } - // Drag and drop: pages reorder, parents apply. - resp.dnd_set_drag_payload(PageDrag::Page(abs)); - if resp.dragged() { - drag_ghost(ui, &app.session.page_label(abs), &t); - } - if dragging && resp.contains_pointer() { - ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); - } - if let Some(payload) = resp.dnd_release_payload::() { - drop = Some(((*payload).clone(), abs)); - } - if resp.clicked() || resp.double_clicked() { - if editing_parents { - let _ = app.run("layout.parents.edit", json!({"on": false})); - app.views.clear(); + // Pages narrower than their numbers ("10" under an Extra Small page) are labelled + // once for the spread. + let narrow = sp.pages.len() > 1 + && (first..first + sp.pages.len()).zip(&sp.pages).any(|(abs, p)| { + let w = ui.painter().layout_no_wrap(app.session.page_label(abs), crate::theme::semibold(11.0), Color32::WHITE).size().x; + (p.width as f32 * scale) < w + 6.0 + }); + for (pi, p) in sp.pages.iter().enumerate() { + let abs = first + pi; + let x0 = row.min.x + center + (p.x as f32 - spine) * scale; + let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); + let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); + let selected = abs == cur && !editing_parents; + // Screen readers hear "Page 3". + resp.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, format!("Page {}", app.session.page_label(abs))) + }); + let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; + match thumbnails.then(|| thumb(app, ui.ctx(), abs, th)).flatten() { + Some(tex) => { + ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); + } + None => { + ui.painter().rect_filled(pr, 0.0, tint); + } } - crate::canvas::go_to_page(app, abs); + ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + // Parent prefix in the outer top corner (black on white). + if let Some(pid) = p.parent + && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) + { + let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default(); + let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK); + let w = g.size().x + 2.0; + let br = if p.side == designcraft_doc::PageSide::Left { + egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0)) + } else { + egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0)) + }; + ui.painter().rect_filled(br, 0.0, Color32::WHITE); + ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK); + } + // Drag and drop: pages reorder, parents apply. + resp.dnd_set_drag_payload(PageDrag::Page(abs)); + if resp.dragged() { + drag_ghost(ui, &app.session.page_label(abs), &t); + } + if dragging && resp.contains_pointer() { + ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); + } + if let Some(payload) = resp.dnd_release_payload::() { + drop = Some(((*payload).clone(), abs)); + } + if resp.clicked() || resp.double_clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + app.views.clear(); + } + crate::canvas::go_to_page(app, abs); + } + resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); + // Page number under the page: white on a blue badge when selected. Pages too + // narrow for their numbers share one label under the spread (below). + if narrow { + continue; + } + let name = app.session.page_label(abs); + let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(pr.center().x, pr.max.y + 11.0); + if selected { + let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); + if narrow { + let names: Vec = (first..first + sp.pages.len()).map(|abs| app.session.page_label(abs)).collect(); + let label = match (names.first(), names.last()) { + (Some(a), Some(b)) if names.len() > 1 => format!("{a}\u{2013}{b}"), + (Some(a), _) => a.clone(), + _ => String::new(), + }; + let g = ui.painter().layout_no_wrap(label, crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(row.min.x + center, top + row_h + 11.0); + if cur_spread == Some(si) && !editing_parents { + ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)), 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - } - }, - ); + // Right-click on the empty part of the panel: the short menu. + let rest = ui.available_rect_before_wrap(); + let (_, empty) = ui.allocate_exact_size(vec2(ui.available_width(), rest.height().max(8.0)), Sense::click()); + empty.context_menu(|ui| empty_menu(app, ui)); + }, + ); + }; + // The divider: drag it to show more or fewer parents. + let divider = |app: &mut DesignApp, ui: &mut egui::Ui| { + let (dr, dresp) = ui.allocate_exact_size(vec2(ui.available_width(), DIVIDER_H), Sense::drag()); + let line = egui::Rect::from_center_size(dr.center(), vec2(dr.width() + 20.0, 1.5)); + ui.painter().rect_filled(line, 0.0, if dresp.hovered() || dresp.dragged() { t.text_dim } else { t.section_divider }); + let dresp = dresp.on_hover_cursor(egui::CursorIcon::ResizeVertical); + if dresp.dragged() { + let dy = dresp.drag_delta().y * if opts.pages_on_top { -1.0 } else { 1.0 }; + app.ui.pages_parents_height = Some([(parents_h + dy).clamp(parents_min, parents_max), space]); + } + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Parents and pages divider")); + }; + if opts.pages_on_top { + pages_part(app, ui); + divider(app, ui); + parents_part(app, ui); + } else { + parents_part(app, ui); + divider(app, ui); + pages_part(app, ui); + } ui.spacing_mut().item_spacing.y = 4.0; ui.horizontal(|ui| { ui.set_min_height(FOOTER_H - 4.0); @@ -315,6 +543,39 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { page_menu(app, ui, &doc, cur, si); } +/// Right-click on the panel's empty part, as InDesign's: the commands that need no page. +fn empty_menu(app: &mut DesignApp, ui: &mut egui::Ui) { + let lang = app.ui.language.clone(); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked(); + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": cur, "count": 1}))); + ui.close(); + } + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": cur, "to": cur}))); + ui.close(); + } + ui.separator(); + if item(ui, "Panel Options…") { + open_panel_options(app); + ui.close(); + } +} + +/// Panel Options…, with the panel's current options. +pub fn open_panel_options(app: &mut DesignApp) { + let o = &app.ui.pages_panel; + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "pagesPanelOptions", + json!({ + "pageSize": o.page_size.key(), "pageThumbnails": o.page_thumbnails, + "parentSize": o.parent_size.key(), "parentsVertical": o.parents_vertical, + "layout": if o.pages_on_top { "pagesOnTop" } else { "parentsOnTop" }, "resize": o.resize.key(), + }), + )); +} + fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); @@ -403,6 +664,11 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); ui.close(); } + ui.separator(); + if item(ui, "Panel Options…") { + open_panel_options(app); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -519,6 +785,60 @@ mod tests { 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); + } + #[test] fn the_panel_menu_follows_indesigns_order() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 8f93a48c4dd2f35f80d14d6f498114b7427a995f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 04:56:38 +0000 Subject: [PATCH 3/4] Integration: #1067 Panel Options' parent Show Thumbnails and size drive the parents' rendered thumbnails --- crates/ui-egui/src/dialogs.rs | 4 ++++ crates/ui-egui/src/panels/pages.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8fae26cf..358416a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2606,6 +2606,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, crate::i18n::tr(&lang, "Size:")); combo(ui, &mut d, "parentSize", &sizes); }); + ui.add_enabled_ui(d.s("parentSize") != "extraSmall", |ui| { + check(ui, &mut d, "parentThumbnails", crate::i18n::tr(&lang, "Show Thumbnails")) + }); check(ui, &mut d, "parentsVertical", crate::i18n::tr(&lang, "Show Vertically")); group(ui, "Panel Layout"); for (v, label) in [("pagesOnTop", "Pages on Top"), ("parentsOnTop", "Parents on Top")] { @@ -3452,6 +3455,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { o.page_size = IconSize::from_key(&d.s("pageSize")).unwrap_or(o.page_size); o.page_thumbnails = d.b("pageThumbnails"); o.parent_size = IconSize::from_key(&d.s("parentSize")).unwrap_or(o.parent_size); + o.parent_thumbnails = d.b("parentThumbnails"); o.parents_vertical = d.b("parentsVertical"); o.pages_on_top = d.s("layout") == "pagesOnTop"; o.resize = PanelResize::from_key(&d.s("resize")).unwrap_or(o.resize); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 3fbb2fd5..4a7f98f0 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -160,6 +160,7 @@ pub struct PanelOptions { pub page_size: IconSize, pub page_thumbnails: bool, pub parent_size: IconSize, + pub parent_thumbnails: bool, pub parents_vertical: bool, pub pages_on_top: bool, pub resize: PanelResize, @@ -171,6 +172,7 @@ impl Default for PanelOptions { page_size: IconSize::Medium, page_thumbnails: true, parent_size: IconSize::Small, + parent_thumbnails: true, parents_vertical: true, pages_on_top: false, resize: PanelResize::ParentsFixed, @@ -289,8 +291,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .max(160.0); let row_h = opts.parent_size.row_h(); let icon_h = row_h - 5.0; - // Extra Small has no room for a thumbnail. - let parent_thumbnails = opts.parent_size != IconSize::ExtraSmall; + // Extra Small has no room for a thumbnail; Show Thumbnails off draws blank pages. + let parent_thumbnails = opts.parent_thumbnails && opts.parent_size != IconSize::ExtraSmall; // Parents side by side (Show Vertically off): tiles of the icon and the prefix, wrapping. let tile_w = |unit_w: f32| (icon_h * unit_w + 16.0).max(44.0); let natural = if opts.parents_vertical { @@ -652,7 +654,7 @@ pub fn open_panel_options(app: &mut DesignApp) { "pagesPanelOptions", json!({ "pageSize": o.page_size.key(), "pageThumbnails": o.page_thumbnails, - "parentSize": o.parent_size.key(), "parentsVertical": o.parents_vertical, + "parentSize": o.parent_size.key(), "parentThumbnails": o.parent_thumbnails, "parentsVertical": o.parents_vertical, "layout": if o.pages_on_top { "pagesOnTop" } else { "parentsOnTop" }, "resize": o.resize.key(), }), )); @@ -1018,6 +1020,27 @@ mod tests { 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. + #[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()); From 763c4fac08fdbb043076726651f12779637db886 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 05:01:19 +0000 Subject: [PATCH 4/4] Integration: #1067 large parent icons in a short Pages panel don't crash --- crates/ui-egui/src/panels/pages.rs | 34 ++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4a7f98f0..0562b4ed 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -312,7 +312,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }; let space = (total - FOOTER_H - DIVIDER_H).max(2.0 * ROW_H); let parents_max = (space - 2.0 * ROW_H).max(ROW_H); - let parents_min = row_h.min(natural); + // A row taller than the room left (large icons in a short panel) gets that room. + // A row taller than the room left (large icons in a short panel) gets that room. + let parents_min = row_h.min(natural).min(parents_max); // The divider's place as last dragged, and how a change of the panel's height is shared. let parents_h = match app.ui.pages_parents_height { None => natural.min(PARENTS_DEFAULT_ROWS * ROW_H), @@ -814,7 +816,11 @@ mod tests { } fn harness(app: DesignApp) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + 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::new("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { @@ -1022,6 +1028,30 @@ mod tests { /// 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| {