diff --git a/ROADMAP.md b/ROADMAP.md index 33b6ed93..0718a4e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -400,6 +400,8 @@ Newest first. - **2026-10-15** · Window › Styles › Paragraph Styles (or Character Styles) opens InDesign's floating Character Styles | Paragraph Styles group, which stays open while other panels open; it was a flyout that the next panel replaced. No readiness percentage change. - **2026-10-15** · Double-clicking the status bar's preflight count or its light shows the Preflight panel, as in InDesign. - **2026-10-15** · A tool group's flyout closes on Esc and on a click anywhere outside it (a panel, the Fill swatch), as InDesign's; it only closed on a click on the canvas. +- **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-15** · IDML: ruler guides import and export (they were dropped). Each page's guides come in measured from the document's ruler origin (spread, page or spine), with Fit to Page, Locked and their layer, parent pages' included, and go out again under their pages. No readiness percentage change. - **2026-10-15** · Find Font no longer reports a style missing when it differs from the installed one only in spacing or case (InDesign files can name `Regular ` with a trailing space); the text was already drawn in the right face. No readiness percentage change. - **2026-10-15** · A line of text is set when its baseline fits in the frame, as in InDesign: descenders may hang below the frame's bottom. A frame sized to the cap height of a big heading (103 pt in a 2.77 cm frame, as Zero2Graphic's magazine tutorial draws them) no longer oversets. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 656f95d7..358416a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2066,6 +2066,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), "deletePages" => crate::i18n::tr(&app.ui.language, "Warning"), + "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"), @@ -2097,7 +2098,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" | "deletePages" | "newWorkspace" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2582,6 +2583,45 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.add_space(8.0); check(ui, &mut d, "dontShowAgain", crate::i18n::tr(&app.ui.language, "Don't show again")); } + "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); + }); + 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")] { + 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, @@ -3409,6 +3449,18 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("layout.pages.delete", json!({"pages": d.fields.get("pages").cloned().unwrap_or_else(|| json!([]))})) } + "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.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); + 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/dock.rs b/crates/ui-egui/src/dock.rs index 3369ed98..7e7ce2a6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -114,8 +114,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), }); @@ -242,7 +246,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); }); }); }); @@ -251,6 +255,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 { @@ -382,7 +401,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/i18n.rs b/crates/ui-egui/src/i18n.rs index ced4d473..efe089ef 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -131,6 +131,27 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Minimize", ["Minimieren", "Réduire", "Minimizar", "最小化", "最小化"]), ("Collapse to Icons", ["Auf Symbole minimieren", "Réduire en icônes", "Contraer a iconos", "アイコンパネルに折りたたむ", "折叠为图标"]), + ("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 802e9923..b050de38 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -57,6 +57,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "إغلاق مجموعة علامات التبويب"), ("Minimize", "تصغير"), ("Collapse to Icons", "طي إلى أيقونات"), + ("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 b96280e0..efc12885 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -42,6 +42,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "Registerkartengruppe schließen"), ("Minimize", "Minimieren"), ("Collapse to Icons", "Auf Symbole minimieren"), + ("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 6201003d..a918a982 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -56,6 +56,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "Chiudi gruppo schede"), ("Minimize", "Minimizza"), ("Collapse to Icons", "Riduci a icone"), + ("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 5acaa5e3..5647ca94 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -59,6 +59,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "タブグループを閉じる"), ("Minimize", "最小化"), ("Collapse to Icons", "アイコンパネルに折りたたむ"), + ("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 f63f561d..975da9ca 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -42,6 +42,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "ჩანართების ჯგუფის დახურვა"), ("Minimize", "ჩაკეცვა"), ("Collapse to Icons", "ხატულებად ჩაკეცვა"), + ("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 75e0af3c..8521de59 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -59,6 +59,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "Fechar grupo de guias"), ("Minimize", "Minimizar"), ("Collapse to Icons", "Recolher para ícones"), + ("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 6c4cd1ce..dd4a1434 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -57,6 +57,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Close Tab Group", "Закрити групу вкладок"), ("Minimize", "Згорнути"), ("Collapse to Icons", "Згорнути до піктограм"), + ("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 e6600890..79eb2c44 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -215,6 +215,11 @@ pub struct UiState { pub hidden_panels: u8, /// "Don't show again" in the warning before deleting pages that contain objects. pub skip_delete_pages_warning: bool, + /// 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. @@ -373,6 +378,8 @@ impl Default for UiState { flattener_preview: false, hidden_panels: 0, skip_delete_pages_warning: false, + 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/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 6f5d87c4..9b85c0b9 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -632,6 +632,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 6c3e0e10..0562b4ed 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -17,7 +17,8 @@ enum ThumbOf { Parent(SpreadId), } -type ThumbKey = (u64, u64, ThumbOf); +/// (document uid, revision, page or parent, thumbnail height). +type ThumbKey = (u64, u64, ThumbOf, u32); static THUMBS: Mutex>> = Mutex::new(None); @@ -26,7 +27,7 @@ const MAX_THUMB_W: f64 = 1024.0; fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option { let st = app.session.active()?; - let key = (st.uid, st.revision, of); + let key = (st.uid, st.revision, of, 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) { @@ -57,7 +58,7 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Optio } }; let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = crate::widgets::load_texture(ctx, name, ci, egui::TextureOptions::LINEAR); + let tex = crate::widgets::load_texture(ctx, format!("{name}_{}", 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)); @@ -65,6 +66,120 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Optio 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 parent_thumbnails: bool, + 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, + parent_thumbnails: true, + parents_vertical: true, + pages_on_top: false, + resize: PanelResize::ParentsFixed, + } + } +} + /// What is being dragged in the Pages panel. #[derive(Clone, Debug, PartialEq)] enum PageDrag { @@ -74,6 +189,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. @@ -83,30 +204,47 @@ const THUMB_H: f32 = 37.0; /// Parent rows. const ROW_H: f32 = 29.0; -/// Parent icon height. -const PARENT_THUMB_H: f32 = 24.0; +/// A row of the Pages panel's parents list: [None] (`parent` = `None`) or a parent. +struct ParentRow { + label: String, + prefix: Option, + /// Index in `doc.parents` and id. + parent: Option<(usize, SpreadId)>, + /// Page outlines (left, width) and the icon's width, for an icon 1 high. + pages: Vec<(f32, f32)>, + unit_w: f32, +} -/// Paint a parent's icon right-aligned at `right`: its rendered spread, or blank pages of `aspect` -/// for [None] (`parent` = `None`) and when the spread can't be rendered. -fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, parent: Option<(usize, SpreadId)>, aspect: f32, t: &Tokens) { - let h = PARENT_THUMB_H; - let doc = app.session.active().map(|st| st.doc.clone()); - let spread = parent.and_then(|(i, _)| doc.as_ref()?.parents.get(i).cloned()); - // Page outlines in icon coordinates (left, width), from the spread's own pages. - let (pages, total): (Vec<(f32, f32)>, f32) = match &spread { - Some(sp) if sp.bounds().height() > 0.0 => { - let b = sp.bounds(); - let k = h as f64 / b.height(); +impl ParentRow { + /// A parent's row, from the spread's own pages; blank pages of `aspect` when it has no size. + fn new(i: usize, sp: &designcraft_doc::Spread, aspect: f32) -> ParentRow { + let b = sp.bounds(); + let (pages, unit_w) = if b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite() { + let k = 1.0 / b.height(); (sp.pages.iter().map(|p| (((p.x - b.x0) * k) as f32, (p.width * k) as f32)).collect(), (b.width() * k) as f32) + } else { + let n = sp.pages.len().max(1); + ((0..n).map(|k| (aspect * k as f32, aspect)).collect(), aspect * n as f32) + }; + ParentRow { + label: sp.parent.as_ref().map(|i| i.label()).unwrap_or_default(), + prefix: sp.parent.as_ref().map(|i| i.prefix.clone()), + parent: Some((i, sp.id)), + pages, + unit_w, } - _ => { - let n = spread.as_ref().map_or(1, |sp| sp.pages.len().max(1)); - let w = h * aspect; - ((0..n).map(|k| (w * k as f32, w)).collect(), w * n as f32) - } - }; - let area = egui::Rect::from_min_size(egui::pos2(right.x - total, right.y - h / 2.0), vec2(total, h)); - let tex = parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h)); + } + + fn none(aspect: f32) -> ParentRow { + ParentRow { label: "[None]".into(), prefix: None, parent: None, pages: vec![(0.0, aspect)], unit_w: aspect } + } +} + +/// Paint a parent's icon `h` high, right-aligned at `right`: its rendered spread (when +/// `thumbnails`), or blank pages for [None] and when the spread can't be rendered. +fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &ParentRow, h: f32, thumbnails: bool, t: &Tokens) { + let area = egui::Rect::from_min_size(egui::pos2(right.x - row.unit_w * h, right.y - h / 2.0), vec2(row.unit_w * h, h)); + let tex = if thumbnails { row.parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h)) } else { None }; let p = ui.painter(); match tex { Some(tex) => { @@ -116,8 +254,8 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, parent: Op p.rect_filled(area, 0.0, Color32::WHITE); } } - for (x, w) in pages { - let r = egui::Rect::from_min_size(egui::pos2(area.min.x + x, area.min.y), vec2(w, h)); + for (x, w) in &row.pages { + let r = egui::Rect::from_min_size(egui::pos2(area.min.x + x * h, area.min.y), vec2(w * h, h)); p.rect_stroke(r, 0.0, Stroke::new(0.5, t.border), StrokeKind::Inside); } } @@ -130,7 +268,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32; let mut drop: Option<(PageDrag, usize)> = None; ui.spacing_mut().item_spacing.y = 0.0; - // ---- Parents: [None] and each parent, 29 pt rows with the parent's thumbnail at the right. + // ---- Parents: [None] and each parent, rows with the parent's thumbnail at the right. // While parents are edited, the row of the parent the canvas shows is selected. let cur_parent = if editing_parents { let layout = designcraft_tools::CanvasLayout::new(&doc, true); @@ -141,121 +279,277 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { None }; - let mut rows: Vec<(String, Option<(usize, SpreadId)>)> = vec![("[None]".into(), None)]; - for (i, p) in doc.parents.iter().enumerate() { - let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - rows.push((label, Some((i, p.id)))); + let mut rows = vec![ParentRow::none(aspect)]; + rows.extend(doc.parents.iter().enumerate().map(|(i, p)| ParentRow::new(i, p, aspect))); + // 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 } } - for (label, parent) in rows { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); - let active = parent.is_some_and(|(i, _)| cur_parent == Some(i)); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &label)); - 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); - parent_icon(app, ui, egui::pos2(r.max.x - 10.0, r.center().y), parent, aspect, &t); - resp.dnd_set_drag_payload(PageDrag::Parent(parent.map(|(_, id)| id))); - if resp.double_clicked() - && let Some((_, id)) = parent - { - let _ = app.run("layout.parents.edit", json!({"on": true, "parent": id.0})); - } - if resp.dragged() { - drag_ghost(ui, &label, &t); + .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; 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 { + 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 row in &rows { + let w = tile_w(row.unit_w) + 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); + // 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), + Some([h, at]) => match opts.resize { + PanelResize::ParentsFixed => h, + PanelResize::PagesFixed => h + (space - at), + PanelResize::Proportional => h * space / at.max(1.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); + .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, row: &ParentRow| { + let label = row.label.as_str(); + 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(row.unit_w), icon_h + 22.0), Sense::click_and_drag()) + }; + let active = row.parent.is_some_and(|(i, _)| cur_parent == Some(i)); + 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, + ); + parent_icon(app, ui, egui::pos2(r.max.x - 10.0, r.center().y), row, icon_h, parent_thumbnails, &t); + } else { + let w = icon_h * row.unit_w; + parent_icon(app, ui, egui::pos2(r.center().x + w / 2.0, r.min.y + 3.0 + icon_h / 2.0), row, icon_h, parent_thumbnails, &t); + let short = row.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::selected(egui::WidgetType::SelectableLabel, true, active, label)); + let resp = if opts.parents_vertical { resp } else { resp.on_hover_text(label) }; + resp.dnd_set_drag_payload(PageDrag::Parent(row.parent.map(|(_, id)| id))); + if resp.double_clicked() + && let Some((_, id)) = row.parent + { + let _ = app.run("layout.parents.edit", json!({"on": true, "parent": id.0})); + } + if resp.dragged() { + drag_ghost(ui, label, &t); + } + }; + if opts.parents_vertical { + for row in &rows { + each(app, ui, row); + } + } else { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 0.0); + for row in &rows { + each(app, ui, row); + } + }); + } + }, + ); + }; // ---- 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(), ThumbOf::Page(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})); - // The canvas refits to the page's spread once it is centred. - if let Some(v) = app.view_mut() { - v.fitted = false; + 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, + )); + } + // 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(), ThumbOf::Page(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); + } + } + 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})); + // The canvas refits to the page's spread once it is centred. + if let Some(v) = app.view_mut() { + v.fitted = false; + } + } + 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); + } + 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); } } - 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); + // 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.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( @@ -335,6 +629,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(), "parentThumbnails": o.parent_thumbnails, "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(); @@ -423,6 +750,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. @@ -484,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() { @@ -600,6 +936,141 @@ mod tests { 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 = 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"); + } + + /// 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());