From 5c103c280e67cbc828809a5415dd53ebf245bb20 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:58:50 -0700 Subject: [PATCH 01/16] =?UTF-8?q?Story=20Editor:=20a=20depth=20ruler=20bes?= =?UTF-8?q?ide=20the=20text,=20and=20View=20=E2=80=BA=20Story=20Editor=20?= =?UTF-8?q?=E2=80=BA=20Show/Hide=20Depth=20Ruler,=20as=20in=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Story Editor has a vertical depth ruler between the style column and the text. It is marked in the document's vertical ruler units with how deep the story's text fills its frames, counted through its columns and threaded frames, and it is shaded down to the overset point. Paragraph spacing adds to the depth. Lines set side by side, in the sub-columns of a split paragraph or on both sides of a wrap object, count once. Where several marks fall on one editor row, only the deepest is drawn. A red line crosses the text, and the ruler when it shows, above the first character that does not fit. View › Story Editor › Show/Hide Depth Ruler (view.storyEditorDepthRuler) shows or hides the ruler in the Story Editor, for every story opened in it. The ruler is shown by default, and the setting is saved with the preferences. --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 12 ++ crates/ui-egui/src/story_editor.rs | 331 ++++++++++++++++++++++++++++- docs/gaps.md | 2 +- docs/ui-parity.md | 2 +- 17 files changed, 359 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bb70baf15..3ae68ebca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -364,6 +364,7 @@ Newest first. - **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change. - **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. - **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. +- **2026-10-24** · The Story Editor has InDesign's vertical depth ruler between the style column and the text, and View › Story Editor › Show/Hide Depth Ruler (`view.storyEditorDepthRuler`) shows or hides it in the Story Editor, remembered with the preferences. The ruler is marked in the document's vertical ruler units with how deep the story's text fills its frames, counted through its columns and threaded frames (lines set side by side, in the sub-columns of a split paragraph or on both sides of a wrap object, count once), and is shaded down to the overset point, where a red line now crosses the editor. No readiness percentage change. - **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. - **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change. - **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 19e9d911d..e2a2cbe9c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2247,6 +2247,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ "显示/隐藏基线网格", ], ), + ( + "Show/Hide Depth Ruler", + ["Show/Hide Depth Ruler", "Afficher/Masquer la règle de profondeur", "Show/Hide Depth Ruler", "Show/Hide Depth Ruler", "显示/隐藏深度标尺"], + ), ( "Show/Hide Document Grid", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index beafe4b84..fa21aea34 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1553,6 +1553,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "تعيين الظهور / القفل"), ("Show Panel", "إظهار اللوحة"), ("Show/Hide Baseline Grid", "إظهار/إخفاء شبكة خط الأساس"), + ("Show/Hide Depth Ruler", "إظهار/إخفاء مسطرة العمق"), ("Show/Hide Document Grid", "إظهار/إخفاء شبكة المستند"), ("Show/Hide Frame Edges", "إظهار/إخفاء حواف الإطارات"), ("Show/Hide Guides", "إظهار/إخفاء الأدلة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2f12068a1..4e0a9bb9a 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -709,6 +709,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Nastavit viditelnost / zámek"), ("Show Panel", "Zobrazit panel"), ("Show/Hide Baseline Grid", "Zobrazit/skrýt mřížku účaří"), + ("Show/Hide Depth Ruler", "Zobrazit/skrýt pravítko hloubky"), ("Show/Hide Frame Edges", "Zobrazit/skrýt okraje rámečků"), ("Show/Hide Guides", "Zobrazit/skrýt vodítka"), ("Show/Hide Hidden Characters", "Zobrazit/skrýt skryté znaky"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 35a566ad6..611e5129d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -746,6 +746,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Sichtbarkeit/Sperre festlegen"), ("Show Panel", "Bedienfeld einblenden"), ("Show/Hide Baseline Grid", "Grundlinienraster ein-/ausblenden"), + ("Show/Hide Depth Ruler", "Tiefenlineal ein-/ausblenden"), ("Show/Hide Frame Edges", "Rahmenkanten ein-/ausblenden"), ("Show/Hide Guides", "Hilfslinien ein-/ausblenden"), ("Show/Hide Hidden Characters", "Verborgene Zeichen ein-/ausblenden"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index d81a34df9..f62611d25 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -837,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Imposta visibilità / blocco"), ("Show Panel", "Mostra pannello"), ("Show/Hide Baseline Grid", "Mostra/nascondi griglia linee di base"), + ("Show/Hide Depth Ruler", "Mostra/nascondi righello profondità"), ("Show/Hide Document Grid", "Mostra/nascondi griglia documento"), ("Show/Hide Frame Edges", "Mostra/nascondi bordi cornice"), ("Show/Hide Guides", "Mostra/nascondi guide"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9c57319cb..d56f77a3c 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -938,6 +938,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "表示・ロックを設定"), ("Show Panel", "パネルを表示"), ("Show/Hide Baseline Grid", "ベースライングリッドを表示・非表示"), + ("Show/Hide Depth Ruler", "深さ定規を表示・非表示"), ("Show/Hide Document Grid", "ドキュメントグリッドを表示・非表示"), ("Show/Hide Frame Edges", "フレームの枠を表示・非表示"), ("Show/Hide Guides", "ガイドを表示・非表示"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 61196d196..29c2258ce 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -765,6 +765,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "ხილვადობის / ჩაკეტვის დაყენება"), ("Show Panel", "პანელის ჩვენება"), ("Show/Hide Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება/დამალვა"), + ("Show/Hide Depth Ruler", "სიღრმის სახაზავის ჩვენება/დამალვა"), ("Show/Hide Frame Edges", "ჩარჩოს კიდეების ჩვენება/დამალვა"), ("Show/Hide Guides", "დამხმარე ხაზების ჩვენება/დამალვა"), ("Show/Hide Hidden Characters", "დამალული სიმბოლოების ჩვენება/დამალვა"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 4dffa9de3..7f4249bb5 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -716,6 +716,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Ustaw widoczność / blokadę"), ("Show Panel", "Pokaż panel"), ("Show/Hide Baseline Grid", "Pokaż/ukryj siatkę linii bazowych"), + ("Show/Hide Depth Ruler", "Pokaż/ukryj linijkę głębokości"), ("Show/Hide Frame Edges", "Pokaż/ukryj krawędzie ramek"), ("Show/Hide Guides", "Pokaż/ukryj linie pomocnicze"), ("Show/Hide Hidden Characters", "Pokaż/ukryj ukryte znaki"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 4628baffa..293c2a3a0 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1684,6 +1684,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Definir visibilidade / bloqueio"), ("Show Panel", "Mostrar painel"), ("Show/Hide Baseline Grid", "Mostrar/ocultar grade de linha de base"), + ("Show/Hide Depth Ruler", "Mostrar/ocultar régua de profundidade"), ("Show/Hide Frame Edges", "Mostrar/ocultar bordas dos quadros"), ("Show/Hide Guides", "Mostrar/ocultar guias"), ("Show/Hide Hidden Characters", "Mostrar/ocultar caracteres ocultos"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index f13694bb9..9c46ba7bc 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -696,6 +696,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Задать видимость / блокировку"), ("Show Panel", "Показать панель"), ("Show/Hide Baseline Grid", "Показать/скрыть сетку базовых линий"), + ("Show/Hide Depth Ruler", "Показать/скрыть линейку глубины"), ("Show/Hide Frame Edges", "Показать/скрыть границы рамок"), ("Show/Hide Guides", "Показать/скрыть направляющие"), ("Show/Hide Hidden Characters", "Показать/скрыть скрытые символы"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5dca5b717..93fe795d8 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -840,6 +840,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Установити видимість / блокування"), ("Show Panel", "Показати панель"), ("Show/Hide Baseline Grid", "Показати/приховати сітку базових ліній"), + ("Show/Hide Depth Ruler", "Показати/приховати лінійку глибини"), ("Show/Hide Document Grid", "Показати/приховати сітку документа"), ("Show/Hide Frame Edges", "Показати/приховати межі фреймів"), ("Show/Hide Guides", "Показати/приховати напрямні"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96c2c6eda..3b471395b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -241,6 +241,8 @@ pub struct UiState { pub dynamic_spelling: bool, /// Preferences › Story Editor Display: text size (points). pub story_editor_size: f32, + /// View › Story Editor › Show/Hide Depth Ruler. + pub story_editor_depth_ruler: bool, /// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`. pub language: String, /// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export. @@ -391,6 +393,7 @@ impl Default for UiState { hidden_menu_items: Vec::new(), show_full_menus: false, story_editor_size: 14.0, + story_editor_depth_ruler: true, scripts: vec![ ("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()), ("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5e751fbe9..9f473ccce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -215,6 +215,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), ("view.hiddenCharacters", "Show Hidden Characters", Some("Cmd+Alt+I"), "{}"), ("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"), + ( + "view.storyEditorDepthRuler", + "Show/Hide Depth Ruler", + None, + "{} — the Story Editor's vertical ruler of how deep the text reaches in its frames", + ), ("window.hidePanels", "Show/Hide Panels", Some("Tab"), "{} — every panel, the Tools panel included"), ("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"), ("window.nextDocument", "Next Document", Some("Cmd+F6"), "{}"), @@ -860,6 +866,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.taggedFrames", "ui:view.tagMarkers", "<", + ">Story Editor", + "ui:view.storyEditorDepthRuler", + "<", ], ), ( @@ -1578,6 +1587,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.text_threads), "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters), "view.taggedFrames" => flag(&mut app.ui.tagged_frames), + "view.storyEditorDepthRuler" => flag(&mut app.ui.story_editor_depth_ruler), "window.hidePanels" | "window.hidePanelsExceptTools" => { let mode = if id == "window.hidePanels" { 1 } else { 2 }; app.ui.hidden_panels = if app.ui.hidden_panels == mode { 0 } else { mode }; @@ -2465,6 +2475,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.textThreads" => app.ui.text_threads, "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, + "view.storyEditorDepthRuler" => app.ui.story_editor_depth_ruler, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies // to (selected text, cells or text frames) has it. @@ -3404,6 +3415,7 @@ mod tests { "view.separations", "view.taggedFrames", "view.tagMarkers", + "view.storyEditorDepthRuler", ] { assert!(view.contains(&id), "{id} is in View"); assert!(!typ.contains(&id), "{id} isn't in Type"); diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index 2bd481d82..3d8356bf7 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -1,15 +1,23 @@ //! Story Editor (Edit → Edit in Story Editor, ⌘Y): the story as plain text with each paragraph's -//! style in the left column and the overset point marked. Edits are applied as minimal range -//! replacements, so formatting around them is kept. +//! style in the left column, the depth ruler beside it and the overset point marked. Edits are +//! applied as minimal range replacements, so formatting around them is kept. -use egui::vec2; +use egui::{pos2, vec2}; use serde_json::json; +use designcraft_compose::ComposedStory; use designcraft_doc::StoryId; use crate::DesignApp; use crate::theme::Tokens; +/// Width of the depth ruler, in screen points. +const RULER_WIDTH: f32 = 34.0; +/// Least distance between two of the depth ruler's labels, in screen points. +const RULER_LABEL_GAP: f32 = 10.0; +/// Color of the overset line and label. +const OVERSET_RED: egui::Color32 = egui::Color32::from_rgb(230, 40, 40); + /// The story open in the Story Editor. Story ids are counted per document, so the editor is bound /// to the document the story belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -41,6 +49,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let cs = app.session.cache.get(&st.doc, sid, None); let overset = cs.overset_at; + let depths = app.ui.story_editor_depth_ruler.then(|| line_depths(&cs)); + let unit = st.doc.settings.vertical_units; let mut open = true; let mut edit: Option<(usize, usize, String)> = None; let words = story.text.split_whitespace().count().to_string(); @@ -54,6 +64,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // Style column: the labels are painted after the text, at the rows the text really wraps to. let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover()); ui.separator(); + // Depth ruler: painted after the text too, beside the rows the lines start on. + let ruler = depths.as_ref().map(|_| ui.allocate_exact_size(vec2(RULER_WIDTH, 0.0), egui::Sense::hover()).0); let mut text = story.text.clone(); let out = egui::TextEdit::multiline(&mut text) .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) @@ -71,6 +83,33 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim)); }); } + let overset_y = overset.map(|o| { + let ci = char_offsets(&story.text, std::iter::once(o)); + out.galley_pos.y + start_rows(&out.galley, &ci).first().map_or(0.0, |row| row.0) + }); + if let (Some(ruler), Some(depths)) = (ruler, &depths) { + let bottom = overset_y.unwrap_or(out.galley_pos.y + out.galley.size().y); + let bar = egui::Rect::from_min_max(pos2(ruler.min.x, out.galley_pos.y), pos2(ruler.min.x + RULER_WIDTH, bottom)); + ui.painter().rect_filled(bar, 0.0, t.ruler); + let ticks = depth_ticks(depths, unit.ruler_ticks(1.0).0); + let rows = start_rows(&out.galley, &char_offsets(&story.text, ticks.iter().map(|tick| tick.0))); + let marks = ticks.iter().zip(rows).map(|(&(_, depth), row)| (out.galley_pos.y + row.1, depth)); + for (y, depth) in spaced_marks(marks, RULER_LABEL_GAP) { + ui.painter().line_segment([pos2(bar.max.x - 8.0, y), pos2(bar.max.x, y)], egui::Stroke::new(1.0, t.ruler_tick)); + ui.painter().text( + pos2(bar.min.x + 2.0, y - 1.0), + egui::Align2::LEFT_BOTTOM, + crate::canvas::fmt_tick(unit.from_pt(depth)), + egui::FontId::proportional(9.0), + t.ruler_text, + ); + } + } + // The overset line: across the text, and the ruler when it shows, above the first character that doesn't fit. + if let Some(y) = overset_y { + let x0 = ruler.map_or(r.rect.min.x, |rr| rr.min.x); + ui.painter().line_segment([pos2(x0, y), pos2(r.rect.max.x, y)], egui::Stroke::new(1.5, OVERSET_RED)); + } if r.changed() { // Minimal diff: common prefix and suffix. let old = &story.text; @@ -88,7 +127,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(egui::Color32::from_rgb(230, 40, 40)).strong(), + egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(OVERSET_RED).strong(), )); crate::rtl::label( ui, @@ -125,6 +164,128 @@ fn paragraph_start_offsets(rows: impl Iterator) -> Vec starts } +/// The depth ruler's values: for each composed line that fits, its first byte and how deep the +/// story's text fills its frames at that line (points). Each line covers the band of its column +/// from one leading above its bottom (its ascent and descent when larger) to its bottom; the depth +/// is the height of the bands covered so far, added up over the story's columns and threaded +/// frames. A line set below the line before it in the same column also covers the space between +/// them (paragraph spacing, or the room a wrap object takes). Lines set side by side in one column +/// (the sub-columns of a split paragraph, or text on both sides of a wrap object) cover one band, +/// so they count once. +fn line_depths(cs: &ComposedStory) -> Vec<(usize, f64)> { + let mut out = Vec::new(); + let mut depth = 0.0; + for ft in &cs.frames { + let mut covered: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut before: Option<(u32, f64)> = None; + for line in &ft.lines { + let bottom = line.baseline + line.descent; + let mut top = bottom - line.leading.max(line.ascent + line.descent); + if let Some((column, above)) = before + && column == line.column + && above < top + { + top = above; + } + before = Some((line.column, bottom)); + if top.is_finite() && bottom > top { + depth += cover(covered.entry(line.column).or_default(), top, bottom); + } + out.push((line.range.start, depth)); + } + } + out +} + +/// Adds the band `top..bottom` to `bands` (sorted, apart from one another) and returns the height +/// it covers that `bands` did not. +fn cover(bands: &mut Vec<(f64, f64)>, top: f64, bottom: f64) -> f64 { + let overlap: f64 = bands.iter().map(|&(a, b)| (b.min(bottom) - a.max(top)).max(0.0)).sum(); + let (mut a, mut b) = (top, bottom); + bands.retain(|&(s, e)| { + let touches = s <= b + 1e-6 && e >= a - 1e-6; + if touches { + (a, b) = (a.min(s), b.max(e)); + } + !touches + }); + let at = bands.partition_point(|&(s, _)| s < a); + bands.insert(at, (a, b)); + (bottom - top - overlap).max(0.0) +} + +/// The depth ruler's marks, one at the first line whose depth reaches each multiple of `step` +/// (points): `(line's first byte, the multiple)`. A line that passes several multiples takes the +/// deepest. +fn depth_ticks(depths: &[(usize, f64)], step: f64) -> Vec<(usize, f64)> { + if !(step.is_finite() && step > 0.0) { + return Vec::new(); + } + let mut out: Vec<(usize, f64)> = Vec::new(); + let mut reached = 0.0; + for &(at, depth) in depths { + if !depth.is_finite() { + continue; + } + let n = (depth / step).floor(); + if n > reached { + reached = n; + out.push((at, n * step)); + } + } + out +} + +/// The depth ruler's marks `(y, depth)` (y ascending) that are drawn: of the marks on one editor +/// row only the deepest, and a mark on a later row only when it is at least `gap` below the one +/// drawn before it. +fn spaced_marks(marks: impl Iterator, gap: f32) -> Vec<(f32, f64)> { + let mut out: Vec<(f32, f64)> = Vec::new(); + for mark in marks { + match out.last_mut() { + Some(last) if (mark.0 - last.0).abs() < 0.5 => *last = mark, + Some(last) if mark.0 - last.0 < gap => {} + _ => out.push(mark), + } + } + out +} + +/// The editor rows `(top, bottom)` (galley coordinates) that the characters `cis` (ascending) start +/// lines on, in one pass over the rows: at a soft wrap, the row a character starts rather than the +/// end of the row before. +fn start_rows(galley: &egui::Galley, cis: &[usize]) -> Vec<(f32, f32)> { + let mut rows = galley.rows.iter().peekable(); + let mut start = 0; + let mut out = Vec::with_capacity(cis.len()); + for &ci in cis { + while let Some(row) = rows.peek() { + let end = start + row.char_count_including_newline().0; + if ci < end || rows.len() == 1 { + break; + } + start = end; + rows.next(); + } + out.push(rows.peek().map_or((0.0, 0.0), |row| (row.min_y(), row.max_y()))); + } + out +} + +/// Character indices of the byte offsets `bytes` (ascending) in `text`, for the editor's cursors. +fn char_offsets(text: &str, bytes: impl Iterator) -> Vec { + let mut chars = text.char_indices().peekable(); + let mut count = 0; + let mut out = Vec::new(); + for byte in bytes { + while chars.next_if(|&(i, _)| i < byte).is_some() { + count += 1; + } + out.push(count); + } + out +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -144,6 +305,168 @@ mod tests { assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]); } + /// The depth runs on through the columns: it never goes back, and the first line of the second + /// column is deeper than the last line of the first. + #[test] + fn depths_run_through_columns() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Depth ruler line of words. ".repeat(80); + let r = app.run("frame.create", json!({"rect": [72, 72, 372, 272], "content": "text", "text": text, "caret": false})).unwrap(); + let (id, sid) = (r["id"].as_u64().unwrap(), designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + app.run("object.textFrameOptions", json!({"ids": [id], "columns": 2, "gutter": 12})).unwrap(); + let st = app.session.active().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + assert!(cs.overset_at.is_some(), "the fixture overflows its frame"); + let depths = super::line_depths(&cs); + let lines: Vec<_> = cs.frames[0].lines.iter().collect(); + let second = lines.iter().position(|l| l.column == 1).expect("text reaches the second column"); + assert!(second > 0 && depths.len() == lines.len(), "{} lines, {} depths", lines.len(), depths.len()); + assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "depth never goes back: {depths:?}"); + assert!(depths[second].1 > depths[second - 1].1, "the second column adds to the first: {depths:?}"); + } + + /// A split paragraph's sub-columns sit side by side: a line beside one already counted adds + /// nothing, whether the sub-columns hold several lines or one each, and the text after the + /// split paragraphs goes on from the deeper sub-column. + #[test] + fn split_paragraph_sub_columns_count_once() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let text = + format!("Opening line.\n{}\nMiddle line.\nTwo short lines set side by side.\nClosing line.", "Split column words go here. ".repeat(12)); + let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": text, "caret": false})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut doc = (*app.session.active().unwrap().doc).clone(); + for i in [1, 3] { + doc.story_mut(sid).unwrap().paras[i].para.span_columns = Some(designcraft_doc::SpanColumns::Split(2)); + } + let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default()); + let lines = &cs.frames[0].lines; + let depths = super::line_depths(&cs); + assert!(lines.windows(2).any(|w| w[0].para == 1 && w[1].para == 1 && w[1].baseline < w[0].baseline), "para 1 restarts higher"); + let side = + (1..lines.len()).find(|&i| lines[i].para == 3 && lines[i - 1].para == 3 && (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6); + let para3: Vec<_> = lines.iter().filter(|l| l.para == 3).map(|l| (l.baseline, l.x0)).collect(); + let side = side.unwrap_or_else(|| panic!("para 3 sets one line in each sub-column: {para3:?}")); + assert_eq!(depths[side].1, depths[side - 1].1, "the second sub-column's line adds nothing: {depths:?}"); + for (i, l) in lines.iter().enumerate().filter(|(_, l)| l.para == 1).skip(1) { + let beside = lines[..i].iter().any(|o| o.para == 1 && o.baseline >= l.baseline - 1e-6); + if beside { + assert_eq!(depths[i].1, depths[i - 1].1, "line {i} sits beside one counted: {depths:?}"); + } + } + let last = lines.len() - 1; + let col_top = cs.frames[0].columns[0].y0; + let closing = lines[last].baseline + lines[last].descent - col_top; + assert!(depths[last].1 <= closing + 1e-6, "no deeper than the frame holds it: {} > {closing}", depths[last].1); + assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}"); + } + + /// Text wrapping on both sides of an object sets one line as two segments on one baseline: the + /// second segment adds nothing to the depth. + #[test] + fn segments_beside_a_wrap_object_share_their_line_depth() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Words that wrap on both sides of the box in the middle. ".repeat(20); + let r = app.run("frame.create", json!({"rect": [72, 72, 432, 472], "content": "text", "text": text, "caret": false})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let b = app.run("frame.create", json!({"rect": [232, 150, 272, 300], "content": "graphic"})).unwrap(); + app.run("object.textWrap", json!({"ids": [b["id"].as_u64().unwrap()], "mode": "boundingBox", "side": "bothSides"})).unwrap(); + let st = app.session.active().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + let lines = &cs.frames[0].lines; + let depths = super::line_depths(&cs); + let pairs: Vec = (1..lines.len()).filter(|&i| (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6).collect(); + assert!(!pairs.is_empty(), "the fixture sets lines in two segments"); + for i in pairs { + assert_eq!(depths[i].1, depths[i - 1].1, "segment {i} adds no depth: {depths:?}"); + } + assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}"); + } + + /// Space after a paragraph moves the next one down the frame, and the depth goes down with it. + #[test] + fn paragraph_spacing_adds_to_the_depth() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": "First.\nSecond.", "caret": false})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut doc = (*app.session.active().unwrap().doc).clone(); + doc.story_mut(sid).unwrap().paras[0].para.space_after = Some(72.0); + let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default()); + let lines = &cs.frames[0].lines; + let depths = super::line_depths(&cs); + assert_eq!(lines.len(), 2); + let moved = (lines[1].baseline + lines[1].descent) - (lines[0].baseline + lines[0].descent); + assert!(moved > 72.0, "the space after moves the second paragraph down: {moved}"); + assert!((depths[1].1 - depths[0].1 - moved).abs() < 1e-6, "the depth goes down as far: {depths:?}, {moved}"); + } + + /// A mark goes at the first line that reaches a multiple; a line that passes several takes only + /// the deepest. + #[test] + fn ticks_go_at_the_first_line_that_reaches_a_multiple() { + let depths = [(0, 10.0), (5, 30.0), (9, 70.0), (12, 100.0), (20, 200.0), (25, f64::NAN)]; + assert_eq!(super::depth_ticks(&depths, 60.0), vec![(9, 60.0), (20, 180.0)]); + assert!(super::depth_ticks(&depths, 0.0).is_empty()); + assert_eq!(super::char_offsets("aé b", [0, 1, 3, 5].into_iter()), vec![0, 1, 2, 4]); + } + + /// Several lines can start on one editor row (narrow columns, large text): only the deepest of + /// their marks is drawn there, and marks on rows closer than a label skip, so labels never + /// overlap and the ones kept stay spread down the ruler. + #[test] + fn marks_on_one_row_show_only_the_deepest_and_keep_their_spacing() { + let marks = [(20.0, 60.0), (20.0, 120.0), (25.0, 180.0), (40.0, 240.0), (60.0, 300.0)]; + assert_eq!(super::spaced_marks(marks.into_iter(), 10.0), vec![(20.0, 120.0), (40.0, 240.0), (60.0, 300.0)]); + let close = [(0.0, 60.0), (8.0, 120.0), (16.0, 180.0), (24.0, 240.0)]; + assert_eq!(super::spaced_marks(close.into_iter(), 10.0), vec![(0.0, 60.0), (16.0, 180.0)]); + } + + /// A line that starts at a soft wrap in the editor is on the wrapped row, not at the end of the + /// row before it, so the overset line and the marks sit beside the text they belong to. + #[test] + fn a_line_starting_at_a_soft_wrap_is_on_the_wrapped_row() { + let ctx = egui::Context::default(); + let mut galley = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + galley = Some(ui.fonts_mut(|f| f.layout("aaaa bbbb".into(), egui::FontId::proportional(14.0), egui::Color32::WHITE, 40.0))); + }); + output.textures_delta.clear(); + let galley = galley.expect("laid out"); + assert!(galley.rows.len() >= 2, "the fixture wraps: {} rows", galley.rows.len()); + let rows = super::start_rows(&galley, &[0, 4, 5, 9, 50]); + let (first, second) = ((galley.rows[0].min_y(), galley.rows[0].max_y()), (galley.rows[1].min_y(), galley.rows[1].max_y())); + assert_eq!(rows, vec![first, first, second, second, (galley.rows.last().unwrap().min_y(), galley.rows.last().unwrap().max_y())]); + } + + /// View › Story Editor › Show/Hide Depth Ruler: the ruler sits between the style column and the + /// text, which moves right by the ruler's width; the setting is remembered. + #[test] + fn depth_ruler_shows_and_hides_from_the_view_menu() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ruler story", "caret": false})).unwrap(); + app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap(); + assert!(app.ui.story_editor_depth_ruler, "shown by default"); + let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0)); + h.run_steps(2); + let text_x = |h: &egui_kittest::Harness<'_, crate::test_window::Window>| { + h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.value().as_deref() == Some("Ruler story")).rect().min.x + }; + let with = text_x(&h); + h.state_mut().app.run("view.storyEditorDepthRuler", json!({})).unwrap(); + h.run_steps(2); + let without = text_x(&h); + assert!((with - without - super::RULER_WIDTH).abs() < 10.0, "the text moves by the ruler: {with} vs {without}"); + let app = &h.state().app; + assert_eq!(crate::menus::checked(app, "view.storyEditorDepthRuler", &json!({})), Some(false)); + let saved = serde_json::to_vec(&app.ui).unwrap(); + assert!(!crate::UiState::from_preferences_json(&saved).unwrap().story_editor_depth_ruler); + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index ad56bfc37..17b852494 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -188,7 +188,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, Convert Columns, Object ▸ Interactive ▸ Convert to (form fields), - View ▸ Extras badges and Story Editor options. Pages panel ▸ Parent Options (Based on Parent is + View ▸ Extras badges and Story Editor options (Show/Hide Depth Ruler is there). Pages panel ▸ Parent Options (Based on Parent is the `layout.parents.options` command only); overriding a base parent's items on a parent spread itself (overrides work on document pages only). - **Estimate:** 40–70 h (most are thin wrappers over existing engine features). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4366847f3..889ded035 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux. | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | | Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | -| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | +| Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | | Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | | UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | From e1ff9b9ee880dda43f62a8d753d119a45862d3ba Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:42:42 -0700 Subject: [PATCH 02/16] Story Editor: Show/Hide Depth Ruler in Spanish The shared table's Spanish column held the English label. It now reads "Mostrar/ocultar regla de profundidad", and a test checks that every interface language translates the label. --- crates/ui-egui/src/i18n.rs | 8 +++++++- crates/ui-egui/src/story_editor.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e2a2cbe9c..dbacb86e3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2249,7 +2249,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Show/Hide Depth Ruler", - ["Show/Hide Depth Ruler", "Afficher/Masquer la règle de profondeur", "Show/Hide Depth Ruler", "Show/Hide Depth Ruler", "显示/隐藏深度标尺"], + [ + "Show/Hide Depth Ruler", + "Afficher/Masquer la règle de profondeur", + "Mostrar/ocultar regla de profundidad", + "Show/Hide Depth Ruler", + "显示/隐藏深度标尺", + ], ), ( "Show/Hide Document Grid", diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index 3d8356bf7..ad35b8bcb 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -467,6 +467,14 @@ mod tests { assert!(!crate::UiState::from_preferences_json(&saved).unwrap().story_editor_depth_ruler); } + /// The Show/Hide Depth Ruler menu label is translated in every interface language. + #[test] + fn depth_ruler_menu_label_is_translated_in_every_language() { + for (lang, _) in crate::i18n::LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { + assert_ne!(crate::i18n::tr(lang, "Show/Hide Depth Ruler"), "Show/Hide Depth Ruler", "{lang}"); + } + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test] From 41b7611b27cb3853c3ee86939f29e197f6458236 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:53:51 -0700 Subject: [PATCH 03/16] Story Editor: showing or hiding the depth ruler keeps the text's focus The ruler's space is allocated before the text editor, so toggling it moved the editor's automatic widget id, and the editor lost its focus and caret. The editor now has a fixed id. --- crates/ui-egui/src/story_editor.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index ad35b8bcb..189bdb481 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -67,7 +67,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // Depth ruler: painted after the text too, beside the rows the lines start on. let ruler = depths.as_ref().map(|_| ui.allocate_exact_size(vec2(RULER_WIDTH, 0.0), egui::Sense::hover()).0); let mut text = story.text.clone(); + // (a fixed id: the ruler's allocation above would otherwise move the editor's auto id, + // and its focus and caret with it, whenever the ruler is shown or hidden) let out = egui::TextEdit::multiline(&mut text) + .id_salt("story_text") .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) .desired_width(f32::INFINITY) .desired_rows(20) @@ -475,6 +478,26 @@ mod tests { } } + /// Showing or hiding the depth ruler keeps the text's focus and caret: typing after the toggle + /// still reaches the story. + #[test] + fn depth_ruler_toggle_keeps_the_text_focused() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ruler story", "caret": false})).unwrap(); + app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0)); + h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.value().as_deref() == Some("Ruler story")).focus(); + h.run_steps(2); + for (toggle, typed) in [("!", "Ruler story!"), ("?", "Ruler story!?")] { + h.state_mut().app.run("view.storyEditorDepthRuler", json!({})).unwrap(); + h.run_steps(2); + h.event(egui::Event::Text(toggle.into())); + h.run_steps(2); + assert_eq!(story_text(&h.state().app, 0), typed); + } + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test] From 5f77ea123b3622318fdefeb9073007e2b1ce3f46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:39:54 -0700 Subject: [PATCH 04/16] Text frames: column rules run from the top line's ascent to the bottom line's descent A column rule ran from the top of the columns to their bottom, so a frame that the text only half fills drew its rules down to the empty bottom of the frame. InDesign draws them from the top line's ascent to the bottom line's descent. column_rule_rects now takes that extent from the frame's lines, and the top and bottom insets shorten the rule from there. A frame without lines draws no rules. Existing documents draw their column rules at the new length, on screen, in PDF and in PowerPoint export. --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 23 ++++++++---- crates/compose/src/tests.rs | 73 ++++++++++++++++++++++++++++++------- crates/pdf/src/tests.rs | 9 ++++- docs/target-app-parity.md | 2 +- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 97456e8ea..2aacaf52f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. -- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. @@ -361,6 +361,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 543f2430d..ef67963c5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2244,19 +2244,28 @@ fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory) const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0; /// Text Frame Options › Column Rules: one bar per gutter, `column_rule_weight` wide, centred -/// between the facing edges of the two columns and moved by `column_rule_offset`, from the -/// columns' top to their bottom (the text area, inside the insets) shortened by the rule's top -/// and bottom insets. Works in the composed space, so right-to-left and vertical frames (whose -/// columns are laid out in the turned box) get their rules between the columns too. +/// between the facing edges of the two columns and moved by `column_rule_offset`, from the top of +/// the frame's top line (its ascent) to the bottom of its bottom line (its descent), as InDesign +/// draws them, shortened by the rule's top and bottom insets (a negative inset extends it). Works +/// in the composed space, so right-to-left and vertical frames (whose columns are laid out in the +/// turned box) get their rules between the columns too. /// /// A paragraph that spans columns interrupts the rule: the bar stops at the top of its first line -/// and resumes below its last line. Nothing is drawn for a single column or a weight that is not a -/// positive finite number. +/// and resumes below its last line. Nothing is drawn for a frame without lines, a single column or +/// a weight that is not a positive finite number. pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptions) -> Vec { let weight = opts.column_rule_weight; if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 { return Vec::new(); } + let (text_top, text_bottom) = lines + .iter() + .map(|l| (l.baseline - l.ascent, l.baseline + l.descent)) + .filter(|(t, z)| t.is_finite() && z.is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(t, z), (lt, lz)| (t.min(lt), z.max(lz))); + if text_bottom < text_top { + return Vec::new(); + } let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; let (offset, top_inset, bottom_inset) = (finite(opts.column_rule_offset), finite(opts.column_rule_top_inset), finite(opts.column_rule_bottom_inset)); @@ -2268,7 +2277,7 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio for pair in cols.windows(2) { let [a, b] = [pair[0], pair[1]]; let cx = (a.x1 + b.x0) / 2.0; - let (top, bottom) = (a.y0.max(b.y0) + top_inset, a.y1.min(b.y1) - bottom_inset); + let (top, bottom) = (text_top + top_inset, text_bottom - bottom_inset); if bottom <= top { continue; } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 300253aa9..3083261fb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -5703,14 +5703,27 @@ fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, (d, fid, rules) } +/// Returns the top of the first frame's top line (its ascent) and the bottom of its bottom line +/// (its descent). +fn text_extent(d: &Document, fid: ItemId) -> (f64, f64) { + let sid = d.item(fid).unwrap().text_frame().unwrap().story; + let cs = compose_story(d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + assert!(!lines.is_empty()); + let top = lines.iter().map(|l| l.baseline - l.ascent).fold(f64::INFINITY, f64::min); + let bottom = lines.iter().map(|l| l.baseline + l.descent).fold(f64::NEG_INFINITY, f64::max); + (top, bottom) +} + #[test] fn column_rules_sit_in_the_gutters_inside_the_insets() { // Two columns in 0..300 with 6 pt side insets: the gutter is centred on 150. - let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); + let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; assert!((r.center().x - 150.0).abs() < 1e-9 && (r.width() - 2.0).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 10.0).abs() < 1e-9 && (r.y1 - 180.0).abs() < 1e-9, "{r:?}"); + let (top, bottom) = text_extent(&d, fid); + assert!((r.y0 - top).abs() < 1e-9 && (r.y1 - bottom).abs() < 1e-9, "{r:?} for the text {top}..{bottom}"); // Three columns: one rule per gutter, each centred between its two columns. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 3, |_, _| {}); assert_eq!(rules.len(), 2, "{rules:?}"); @@ -5729,16 +5742,36 @@ fn column_rules_sit_in_the_gutters_inside_the_insets() { assert!(rules.is_empty()); } +/// Column rules run from the top line's ascent to the bottom line's descent: text that fills the +/// top of a tall frame gets rules down to its bottom line, not to the bottom of the columns. +#[test] +fn column_rules_run_from_the_top_lines_ascent_to_the_bottom_lines_descent() { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 600.0), 2, |_, _| {}); + let (top, bottom) = text_extent(&d, fid); + assert!(bottom < 300.0, "the text fills the top of the 10..580 columns: {top}..{bottom}"); + assert_eq!(rules.len(), 1, "{rules:?}"); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); + // Without lines there is no rule. + let cols = [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 200.0, 50.0)]; + assert!(column_rule_rects(&cols, &[], &TextFrameOptions { column_rule_weight: 1.0, ..Default::default() }).is_empty()); +} + #[test] fn column_rules_centre_on_unequal_gutters_and_ignore_bad_weights() { let cols = [Rect::new(200.0, 0.0, 300.0, 50.0), Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 180.0, 50.0)]; + // One line in the first column, from 0 to 50. + let (d, sid, _) = doc_with("Text", Rect::new(0.0, 0.0, 100.0, 50.0), ParaAttrs::default()); + let mut line = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (line.x0, line.x1, line.ascent, line.descent, line.baseline) = (0.0, 100.0, 40.0, 10.0, 40.0); + let lines = [line]; let weight = |w: f64| TextFrameOptions { column_rule_weight: w, ..Default::default() }; - let rules = column_rule_rects(&cols, &[], &weight(1.0)); + let rules = column_rule_rects(&cols, &lines, &weight(1.0)); let centres: Vec = rules.iter().map(|r| r.center().x).collect(); assert_eq!(centres, [105.0, 190.0]); - assert!(column_rule_rects(&cols, &[], &weight(f64::NAN)).is_empty()); - assert!(column_rule_rects(&cols, &[], &weight(-1.0)).is_empty()); - assert!((column_rule_rects(&cols, &[], &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); + assert!(rules.iter().all(|r| r.y0 == 0.0 && r.y1 == 50.0), "{rules:?}"); + assert!(column_rule_rects(&cols, &lines, &weight(f64::NAN)).is_empty()); + assert!(column_rule_rects(&cols, &lines, &weight(-1.0)).is_empty()); + assert!((column_rule_rects(&cols, &lines, &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); } #[test] @@ -5751,9 +5784,12 @@ fn column_rules_in_right_to_left_and_vertical_frames() { // Vertical type: the columns stack top to bottom, so the rule runs across the frame. let (d, fid, rules) = ruled("縦書き", Rect::new(100.0, 100.0, 400.0, 300.0), 2, |d, sid| d.story_mut(sid).unwrap().vertical = true); assert_eq!(rules.len(), 1); + let (top, bottom) = text_extent(&d, fid); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); let r = d.text_xf(d.item(fid).unwrap()).transform_rect_bbox(rules[0]); - // Text area: x 106..394, y 110..280; the gutter is centred on y 195. - assert!((r.x0 - 106.0).abs() < 1e-6 && (r.x1 - 394.0).abs() < 1e-6, "{r:?}"); + // Text area: x 106..394, y 110..280; the gutter is centred on y 195, and the one line of + // text runs from the right edge, so the rule is as long as that line is deep. + assert!(r.x0 > 106.0 && (r.x1 - 394.0).abs() < 2.0 && (r.width() - (bottom - top)).abs() < 1e-6, "{r:?}"); assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}"); } @@ -5772,13 +5808,15 @@ fn column_rules_break_around_paragraphs_that_span_columns() { for r in &rules { assert!(r.y1 <= top + 1e-9 || r.y0 >= bottom - 1e-9, "rule {r:?} cuts the span {top}..{bottom}"); } - // The rule resumes below the span and runs to the bottom of the columns. - assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - 380.0).abs() < 1e-9), "{rules:?}"); + // The rule resumes below the span and runs to the bottom line's descent. + let (_, text_bottom) = text_extent(&d, fid); + assert!(text_bottom < 380.0 - 1.0, "the text ends above the columns' bottom: {text_bottom}"); + assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - text_bottom).abs() < 1e-9), "{rules:?}"); } #[test] fn column_rules_take_their_offset_insets_and_tint() { - let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; o.column_rule_offset = 3.0; @@ -5786,13 +5824,22 @@ fn column_rules_take_their_offset_insets_and_tint() { o.column_rule_bottom_inset = 25.0; o.column_rule_tint = 0.4; }); - // Gutter centre 150 moved 3 pt; columns 10..180 shortened to 25..155. + // Gutter centre 150 moved 3 pt; the text's extent shortened by 15 at the top and 25 at the + // bottom. + let (top, bottom) = text_extent(&d, fid); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; - assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - 25.0).abs() < 1e-9 && (r.y1 - 155.0).abs() < 1e-9, "{r:?}"); + assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - (top + 15.0)).abs() < 1e-9 && (r.y1 - (bottom - 25.0)).abs() < 1e-9, "{r:?}"); let sid = d.item(fid).unwrap().text_frame().unwrap().story; let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(cs.frames[0].decos.iter().any(|dc| dc.rect == r && (dc.tint - 0.4).abs() < 1e-6)); + // A negative inset extends the rule past the text. + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let fid = d.story(sid).unwrap().frames[0]; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule_top_inset = -4.0; + }); + let (top, bottom) = text_extent(&d, fid); + assert!(rules.len() == 1 && (rules[0].y0 - (top - 4.0)).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?}"); // Insets that meet leave no rule. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 663ef9169..6e880af44 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -604,8 +604,15 @@ fn column_rules_are_drawn() { o.inset = [0.0; 4]; o.column_rule = on; o.column_rule_weight = 2.0; + // The rules run from the top line's ascent to the bottom line's descent, not the 264 pt + // of the columns. + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &designcraft_compose::ComposeOptions::default()); + let line = &cs.frames[0].lines[0]; + let depth = line.ascent + line.descent; + assert!(cs.frames[0].lines.len() == 1 && depth < 264.0 - 1.0, "{depth}"); let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); - paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - 264.0).abs() < 0.01).map(|r| r.center().x).collect::>() + paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - depth).abs() < 0.01).map(|r| r.center().x).collect::>() }; // 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36. let xs = rules(true); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28551d1a8..b0f2ffab7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -348,7 +348,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. -- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). From fcea4173be19648707956314f2d3d852fe809f18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 21:42:22 -0700 Subject: [PATCH 05/16] Hide stale Story Editor depth marks while text changes Draw the depth ruler and overset line after composition reflects the edited text. Add a paint-output regression for inserting a paragraph break. --- crates/ui-egui/src/story_editor.rs | 45 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index 8056ad194..b7d988416 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -77,6 +77,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .frame(egui::Frame::NONE) .show(ui); let r = out.response; + let changed = r.changed(); let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline))); for (i, y) in starts.into_iter().enumerate() { let Some(para) = story.paras.get(i) else { break }; @@ -86,11 +87,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim)); }); } - let overset_y = overset.map(|o| { + let overset_y = overset.filter(|_| !changed).map(|o| { let ci = char_offsets(&story.text, std::iter::once(o)); out.galley_pos.y + start_rows(&out.galley, &ci).first().map_or(0.0, |row| row.0) }); - if let (Some(ruler), Some(depths)) = (ruler, &depths) { + if let (Some(ruler), Some(depths)) = (ruler, depths.as_ref().filter(|_| !changed)) { let bottom = overset_y.unwrap_or(out.galley_pos.y + out.galley.size().y); let bar = egui::Rect::from_min_max(pos2(ruler.min.x, out.galley_pos.y), pos2(ruler.min.x + RULER_WIDTH, bottom)); ui.painter().rect_filled(bar, 0.0, t.ruler); @@ -113,7 +114,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let x0 = ruler.map_or(r.rect.min.x, |rr| rr.min.x); ui.painter().line_segment([pos2(x0, y), pos2(r.rect.max.x, y)], egui::Stroke::new(1.5, OVERSET_RED)); } - if r.changed() { + if changed { // Minimal diff: common prefix and suffix. let old = &story.text; let pre = old.bytes().zip(text.bytes()).take_while(|(a, b)| a == b).count(); @@ -470,6 +471,44 @@ mod tests { assert!(!crate::UiState::from_preferences_json(&saved).unwrap().story_editor_depth_ruler); } + #[test] + fn depth_marks_wait_for_composition_after_editing() { + fn marks(shape: &egui::epaint::Shape, tick: egui::Color32) -> usize { + match shape { + egui::epaint::Shape::Vec(shapes) => shapes.iter().map(|s| marks(s, tick)).sum(), + egui::epaint::Shape::LineSegment { points, stroke } + if stroke.color == super::OVERSET_RED || (stroke.color == tick && (points[1].x - points[0].x - 8.0).abs() < 0.01) => + { + 1 + } + _ => 0, + } + } + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let text = "First line\nSecond line\nThird line\n".repeat(4); + let r = app.run("frame.create", json!({"rect": [72, 72, 372, 172], "content": "text", "text": text, "caret": false})).unwrap(); + app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .with_accessibility_check(false) + .build_ui_state(|ui, app| super::show(app, ui.ctx()), app); + h.run_steps(3); + let tick = crate::theme::Tokens::get(&h.ctx).ruler_tick; + let count = |h: &egui_kittest::Harness<'_, DesignApp>| h.output().shapes.iter().map(|s| marks(&s.shape, tick)).sum::(); + assert!(count(&h) >= 2, "the fixture paints ruler ticks and an overset line"); + h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Home); + h.run_steps(2); + h.event(egui::Event::Text("Inserted line\n".into())); + h.step(); + assert_eq!(story_text(h.state(), 0), format!("Inserted line\n{text}")); + assert_eq!(count(&h), 0, "the edit frame must not map old composition offsets to new text"); + h.step(); + assert!(count(&h) >= 2, "the next frame paints the updated composition"); + } + /// The Show/Hide Depth Ruler menu label is translated in every interface language. #[test] fn depth_ruler_menu_label_is_translated_in_every_language() { From f4e05f4f2d2b7e2d672a45b03d75b5279f38fcc9 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:46 +0000 Subject: [PATCH 06/16] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 ++++ packaging/windows/check-install-policy.ps1 | 195 +++++++++++++++++++++ packaging/windows/designcraft.wxs | 51 ++---- packaging/windows/install-policy.json | 62 +++++++ packaging/windows/install-policy.wxi | 54 ++++++ packaging/windows/installer-ui.wxs | 52 ++---- packaging/windows/package.ps1 | 2 + packaging/windows/test-install-policy.ps1 | 54 ++++++ 9 files changed, 455 insertions(+), 69 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b..7e847137f 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..dd2895643 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/designcraft.wxs b/packaging/windows/designcraft.wxs index c87ed4f19..11514f032 100644 --- a/packaging/windows/designcraft.wxs +++ b/packaging/windows/designcraft.wxs @@ -1,30 +1,14 @@ - - - + + + - + @@ -36,14 +20,16 @@ - + + + - + @@ -56,16 +42,14 @@ - + - + @@ -76,15 +60,16 @@ - - + + - - - - - + + + + + + diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..91c3024ed --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,62 @@ +{ + "id": "designcraft", + "name": "DesignCraft", + "upgradeCode": "49E87D30-BB68-4896-AA1D-F45C833CFC3C", + "sources": [ + "designcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/designcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "NOTICE", + "designcraft-cli.exe", + "designcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Classes\\Designcraft.Idml\\shell\\open\\command", + "", + "\"[#DesigncraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.idml\\OpenWithProgids", + "Designcraft.Idml", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\designcraft.exe", + "FriendlyAppName", + "DesignCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\designcraft.exe\\shell\\open\\command", + "", + "\"[#DesigncraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\Applications\\designcraft.exe\\SupportedTypes", + ".designcraft", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\designcraft.exe\\SupportedTypes", + ".idml", + "" + ], + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\designcraft.exe", + "", + "[#DesigncraftExe]" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..6db54be98 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index c5c596c0b..d0abfdc06 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,18 +1,11 @@ - - - + @@ -22,7 +15,7 @@ - + @@ -46,8 +39,7 @@ - + @@ -63,8 +55,7 @@ - + @@ -77,8 +68,7 @@ - + @@ -121,16 +111,10 @@ diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index d5ce3f388..922790512 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -114,6 +114,8 @@ Invoke-Native 'wix build' { -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..21199ca7f --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} From 0d51fbd0bb1f1a6e1fe98847cf530855966359fc Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:51:23 +0000 Subject: [PATCH 07/16] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- packaging/windows/installer-ui.wxs | 25 ++++++-- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 85856800b..ae4586eb9 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: designcraft app-dir: DesignCraft + install-relative-path: Programs/ArtCraft/DesignCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd2895643..00b683d70 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index 6db54be98..5c1e2f1c9 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index d0abfdc06..1aac8cb8f 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -55,9 +55,14 @@ - + + - + + + + + @@ -69,14 +74,26 @@ - + - + + + + + + + + + + + + + From abe4847a618670cae225f7eae6ecb3045bc8d097 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:45:51 +0000 Subject: [PATCH 08/16] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7e847137f..ea61d8430 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -35,8 +37,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d70..451295a2e 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f..d455d19d1 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -38,7 +38,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From 01328d169aa03dd736988962150926706203f87e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:09:02 +0000 Subject: [PATCH 09/16] docs(installer): describe the per-user default install location and scope --- docs/mcp.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 581736489..68ad387a6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,13 +73,13 @@ The release packages ship `designcraft-cli` alongside the desktop app, so no bui | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\DesignCraft\designcraft-cli.exe` by default (`C:\Program Files\ArtCraft\DesignCraft\designcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/designcraft-cli` | | macOS | the separate `designcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp +# Windows (PowerShell), default install folder +claude mcp add designcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\DesignCraft\designcraft-cli.exe" mcp # Linux, or macOS with the CLI unzipped onto PATH claude mcp add designcraft -- designcraft-cli mcp ``` From dfe5296522fc81e1c856ca78e8a3f65c3debc1f6 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:25:18 +0000 Subject: [PATCH 10/16] docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 68ad387a6..bd82a0638 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,7 @@ The release packages ship `designcraft-cli` alongside the desktop app, so no bui | Install | CLI | |---|---| -| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\DesignCraft\designcraft-cli.exe` by default (`C:\Program Files\ArtCraft\DesignCraft\designcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\DesignCraft\designcraft-cli.exe` by default (`C:\Program Files\ArtCraft\DesignCraft\designcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/designcraft-cli` | | macOS | the separate `designcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | From d77fdf2d4c55f6013cf16b2db6c49f64ae22e820 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:27:32 -0400 Subject: [PATCH 11/16] fix(ui): dropdown menus grow back after a search with no matches (#1917) * fix(ui): font menus grow back after a search with no matches Every combo box is craft_ui::ComboBox (craft-libs pinned at 62bb0e1d), whose menu follows its contents' height while open (egui#5225); clippy.toml bans egui::ComboBox. The Font menu splits into a header (search field, filters) and a list (font_menu_header, font_menu_list). Font fields show it in a craft_ui::combo::Menu and the style dialogs' Font Family combo boxes in ComboBox::show_ui_with_header, so only the rows scroll and the search field stays in place. The blank and Parent's Font rows sit in the header, above the search. * chore(deps): craft-libs 60d60d48 (dropdown fixes) * chore(deps): craft-libs 4d7b82f7 (dropdown fixes) * chore(deps): craft-libs 92d392cf (#387 merged) * chore(xtask): pdf-deps expects craft-libs 92d392cf --- Cargo.lock | 38 +-- Cargo.toml | 34 +-- clippy.toml | 3 + crates/ui-egui/src/chrome/control_bar.rs | 8 +- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 200 +++++++------- crates/ui-egui/src/dialogs/options.rs | 13 +- crates/ui-egui/src/dialogs/style_cjk.rs | 51 ++-- crates/ui-egui/src/effects_dialog.rs | 16 +- crates/ui-egui/src/new_document.rs | 17 +- crates/ui-egui/src/panels.rs | 34 ++- crates/ui-egui/src/panels/cell_stroke.rs | 2 +- crates/ui-egui/src/panels/datamerge.rs | 2 +- crates/ui-egui/src/panels/font_menu.rs | 254 ++++++++++-------- .../ui-egui/src/panels/font_menu_ui_tests.rs | 63 +++++ crates/ui-egui/src/panels/glyphs.rs | 2 +- crates/ui-egui/src/panels/interactive.rs | 8 +- .../src/panels/properties/object_panels.rs | 8 +- crates/ui-egui/src/panels/properties/text.rs | 2 +- .../src/panels/properties/typography.rs | 4 +- crates/ui-egui/src/panels/swatches.rs | 4 +- crates/ui-egui/src/panels/table.rs | 4 +- crates/ui-egui/src/section_options.rs | 2 +- crates/ui-egui/src/taskbar.rs | 2 +- docs/pdf-backend.md | 2 +- xtask/src/pdf_deps.rs | 2 +- 26 files changed, 450 insertions(+), 327 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eabe9e52e..4d1d8b80b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "craft-atomic" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "rustix 1.1.5", ] @@ -1012,7 +1012,7 @@ dependencies = [ [[package]] name = "craft-cms" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "rayon", ] @@ -1020,17 +1020,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1053,7 +1053,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-i18n", "craft-ui", @@ -1065,7 +1065,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bytemuck", "hayro", @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "craft-prepress" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-cms", "craft-pdf", @@ -1091,7 +1091,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.9.1", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1125,7 +1125,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -1150,7 +1150,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "objc2 0.6.5", @@ -2635,7 +2635,7 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bytemuck", "hayro-interpret", @@ -2655,7 +2655,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "brotli", "hayro-postscript", @@ -2664,7 +2664,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2682,7 +2682,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2700,12 +2700,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "flate2", "hayro-ccitt", @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 195f39e67..0afeca737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,12 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui", "ui"] } +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. -craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["flatten"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } +craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -80,20 +80,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] } indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } # Which paths stored in documents may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Atomic file replacement for saves and exports (craft-libs). -craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # File › Print through the system print spooler (craft-libs). -craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # ICC colour management: working spaces, transforms, black point compensation (craft-libs). -craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } # The application bar as the window's title bar (craft-libs). -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } egui = "0.37" egui_extras = { version = "0.37", default-features = false, features = ["svg"] } eframe = { version = "0.37", default-features = false } @@ -131,16 +131,16 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } # The hayro crates from craft-libs' vendor/ directory (pdfcraft's, plus the plate mode and decode # limits craft-pdf and craft-prepress need); same revision as craft-pdf. -hayro = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.1" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.0" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.3.0" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0" } +hayro = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..287e14edc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index de44bd4b5..331caa446 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -38,7 +38,7 @@ fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &st let landscape = size.0 > size.1; ui.vertical(|ui| { let cur = panels::properties::preset_name(size.0, size.1); - egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui( + craft_ui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui( ui, |ui| { for (name, w, h) in panels::properties::PAGE_PRESETS { @@ -105,7 +105,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { ui.vertical(|ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); let label = panels::interactive::liquid_rule_label(pg.liquid); - egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( + craft_ui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( ui, |ui| { for (r, l) in panels::interactive::LIQUID_RULES { @@ -368,7 +368,7 @@ fn control_character( let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); let names: Vec = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); let mut pick = None; - egui::ComboBox::from_id_salt("ctl_char_style") + craft_ui::ComboBox::from_id_salt("ctl_char_style") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs))) .width(150.0) .show_ui(ui, |ui| { @@ -382,7 +382,7 @@ fn control_character( let _ = app.run("style.character.apply", json!({"name": n})); } let lang = chars["language"].as_str().unwrap_or("English: USA").to_string(); - egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang)); }); }); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 22937978d..45f821b09 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -223,7 +223,7 @@ fn controls(ui: &mut egui::Ui, v: &mut View, lang: &str) { } ui.separator(); ui.label(tr(lang, "Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(tr(lang, v.key.label().0)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(lang, v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, tr(lang, k.label().0)).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 948c84c83..3fb6a657d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -314,7 +314,7 @@ fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("mode".into(), json!("style")); } let cur = d.s("style"); - egui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| { for name in &styles { if ui.selectable_label(cur == *name, crate::rtl::widget(ui, name)).clicked() { d.fields.insert("style".into(), json!(name)); @@ -343,7 +343,7 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if printers.is_empty() { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "System default")).italics()); } else { - egui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| { for p in &printers { if ui.selectable_label(*p == cur, p).clicked() { d.fields.insert("printer".into(), json!(p)); @@ -423,7 +423,7 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Preset:")); let cur = d.s("preset"); - egui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui( + craft_ui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui( ui, |ui| { for p in PDF_EXPORT_PRESETS { @@ -1508,7 +1508,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) -> setup_row(ui, rtl, |ui| { setup_caption(ui, tr("Intent:"), label_w); let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ds_intent") + craft_ui::ComboBox::from_id_salt("ds_intent") .selected_text(crate::rtl::widget( ui, tr(match cur.as_str() { @@ -1542,7 +1542,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) -> setup_row(ui, rtl, |ui| { setup_caption(ui, tr("Binding:"), label_w); let right_to_left = d.s("binding") == "rightToLeft"; - egui::ComboBox::from_id_salt("ds_binding") + craft_ui::ComboBox::from_id_salt("ds_binding") .selected_text(crate::rtl::widget(ui, tr(if right_to_left { "Right to Left" } else { "Left to Right" }))) .width(150.0) .show_ui(ui, |ui| { @@ -1563,7 +1563,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) -> setup_group(ui, |ui| { setup_row(ui, rtl, |ui| { crate::rtl::label(ui, egui::RichText::new(tr("Page Size:")).color(t.text_strong)); - egui::ComboBox::from_id_salt("ds_preset").selected_text(crate::rtl::widget(ui, &preset)).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ds_preset").selected_text(crate::rtl::widget(ui, &preset)).width(260.0).show_ui(ui, |ui| { for p in designcraft_doc::build::PRESETS { if ui.selectable_label(tr(p.name) == preset, crate::rtl::widget(ui, tr(p.name))).clicked() { // Keep the orientation; the preset's size in the document's units. @@ -1915,7 +1915,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Screen:")); let rich = d.b("richBlack"); - egui::ComboBox::from_id_salt("pref_black") + craft_ui::ComboBox::from_id_salt("pref_black") .selected_text(crate::rtl::widget( ui, crate::i18n::tr( @@ -1935,7 +1935,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Printing / Exporting:")); let rich = d.b("richBlackOutput"); - egui::ComboBox::from_id_salt("pref_black_out") + craft_ui::ComboBox::from_id_salt("pref_black_out") .selected_text(crate::rtl::widget( ui, crate::i18n::tr( @@ -1985,7 +1985,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Relative To:")); let cur = d.s("bg.relativeTo"); - egui::ComboBox::from_id_salt("bg_rel") + craft_ui::ComboBox::from_id_salt("bg_rel") .selected_text(crate::rtl::widget( ui, crate::i18n::tr(&app.ui.language, if cur == "topMargin" { "Top Margin" } else { "Top of Page" }), @@ -2066,7 +2066,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he let cur = d.s("displayQuality"); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Default View:")); - egui::ComboBox::from_id_salt("pref_dq") + craft_ui::ComboBox::from_id_salt("pref_dq") .selected_text(match cur.as_str() { "fast" => crate::i18n::tr(&app.ui.language, "Fast"), "typical" => crate::i18n::tr(&app.ui.language, "Typical"), @@ -2088,7 +2088,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he let cur = d.fields.get("rulerOrigin").and_then(Value::as_str).unwrap_or("spread").to_string(); const ORIGINS: [(&str, &str); 3] = [("spread", "Spread"), ("page", "Page"), ("spine", "Spine")]; let shown = ORIGINS.iter().find(|o| o.0 == cur).map_or("Spread", |o| o.1); - egui::ComboBox::from_id_salt("rulerOrigin") + craft_ui::ComboBox::from_id_salt("rulerOrigin") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown))) .width(140.0) .show_ui(ui, |ui| { @@ -2102,7 +2102,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he for (key, label) in [("horizontalUnits", "Horizontal:"), ("verticalUnits", "Vertical:")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur: Unit = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - egui::ComboBox::from_id_salt(key) + craft_ui::ComboBox::from_id_salt(key) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label()))) .width(140.0) .show_ui(ui, |ui| { @@ -2137,17 +2137,18 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Interface Language")).font(semibold(12.0))); // Each language by its own name, so the list reads the same in every interface language. let current = crate::i18n::LANGUAGES.iter().find(|(code, _)| *code == app.ui.language).map_or("English", |(_, name)| *name); - egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("pref_language") + .width(180.0) + .height(400.0) + .selected_text(crate::rtl::widget(ui, current)) + .show_ui(ui, |ui| { for (code, name) in crate::i18n::picker_languages() { if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() { // Applied by `show` right after this frame, which has `&mut DesignApp`. d.fields.insert("applyLanguage".into(), json!(code)); } } - }, - ); + }); ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0))); check(ui, d, "scaleStrokes", crate::i18n::tr(&app.ui.language, "Include Stroke Weight")); @@ -2392,7 +2393,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(key) + craft_ui::ComboBox::from_id_salt(key) .selected_text(if cur.is_empty() { crate::i18n::tr(&app.ui.language, any) } else { cur.as_str() }) .width(180.0) .show_ui(ui, |ui| { @@ -2412,7 +2413,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); let cur = d.s("objKind"); - egui::ComboBox::from_id_salt("objKind") + craft_ui::ComboBox::from_id_salt("objKind") .selected_text(if cur.is_empty() { crate::i18n::tr(&app.ui.language, "(any)") } else { cur.as_str() }) .show_ui(ui, |ui| { for v in ["", "text", "graphic", "shape", "line", "group"] { @@ -2461,7 +2462,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); - egui::ComboBox::from_id_salt("fcscope") + craft_ui::ComboBox::from_id_salt("fcscope") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) .show_ui(ui, |ui| { for v in ["document", "story", "selection"] { @@ -2571,7 +2572,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "media" => crate::i18n::tr(&app.ui.language, "Media"), _ => crate::i18n::tr(&app.ui.language, "Crop"), }; - egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { for v in designcraft_doc::PdfCrop::ALL.map(designcraft_doc::PdfCrop::name) { if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { d.fields.insert("crop".into(), json!(v)); @@ -2608,7 +2609,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { for pr in profiles.iter().filter(|p| p["kind"] == kind) { let name = pr["name"].as_str().unwrap_or(""); if ui.selectable_label(name == cur, name).clicked() { @@ -2620,7 +2621,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ws_intent") + craft_ui::ComboBox::from_id_salt("ws_intent") .selected_text(crate::rtl::widget( ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())), @@ -2731,7 +2732,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } else { mapped.clone() }; - egui::ComboBox::from_id_salt(("imp_map", w)) + craft_ui::ComboBox::from_id_salt(("imp_map", w)) .selected_text(crate::rtl::widget(ui, &shown)) .width(170.0) .show_ui(ui, |ui| { @@ -2852,7 +2853,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); - egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui( + craft_ui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui( ui, |ui| { for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { @@ -2910,7 +2911,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), }; - egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui( + craft_ui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui( ui, |ui| { for v in ["url", "text", "sms", "email", "vcard"] { @@ -4143,7 +4144,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:")); let cur = d.s("x.tag"); - egui::ComboBox::from_id_salt("export_tag") + craft_ui::ComboBox::from_id_salt("export_tag") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) .show_ui(ui, |ui| { for t in ["[Automatic]", "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "li", "figcaption", "aside", "div"] @@ -4167,7 +4168,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { let alignment_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:")); let a: designcraft_doc::Align = serde_json::from_value(cur(d, "p.align", &pv["align"])).unwrap_or_default(); - egui::ComboBox::from_id_salt("psalign") + craft_ui::ComboBox::from_id_salt("psalign") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, a.label()))) .width(240.0) .show_ui(ui, |ui| { @@ -4201,7 +4202,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let grid_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:")); let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default(); let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1); - egui::ComboBox::from_id_salt("psgridalign") + craft_ui::ComboBox::from_id_salt("psgridalign") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown))) .width(240.0) .show_ui(ui, |ui| { @@ -4265,7 +4266,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo .map(str::to_string) .or(style.based_on.clone()) .unwrap_or_else(|| "[No Paragraph Style]".into()); - let combo = egui::ComboBox::from_id_salt("psbased") + let combo = craft_ui::ComboBox::from_id_salt("psbased") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based))) .width(220.0) .show_ui(ui, |ui| { @@ -4372,7 +4373,7 @@ fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); } let replace = d.s("replaceWith"); - egui::ComboBox::from_id_salt("del_para_replace") + craft_ui::ComboBox::from_id_salt("del_para_replace") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) .width(260.0) .show_ui(ui, |ui| { @@ -4506,7 +4507,7 @@ fn para_measure(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, unit: fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)]) { let cur = para_value(d, pv, key); let shown = opts.iter().find(|o| cur == o.0).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(cur == *v, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { para_set(d, key, json!(v)); @@ -4543,7 +4544,7 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { let mut picked = None; - egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -4570,7 +4571,7 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d StrokeType::Style { name } => name.clone(), k => crate::i18n::tr(lang, k.label()).to_string(), }; - egui::ComboBox::from_id_salt("psbordertype").selected_text(crate::rtl::widget(ui, shown)).width(160.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psbordertype").selected_text(crate::rtl::widget(ui, shown)).width(160.0).show_ui(ui, |ui| { for k in BORDER_TYPES { let ty = if k == "dashed" { json!({"kind": k, "pattern": []}) } else { json!({"kind": k}) }; let Ok(t) = serde_json::from_value::(ty.clone()) else { continue }; @@ -4602,7 +4603,7 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; changed = true; } - egui::ComboBox::from_id_salt(("pscornershape", key, i)) + craft_ui::ComboBox::from_id_salt(("pscornershape", key, i)) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) .width(130.0) .show_ui(ui, |ui| { @@ -4761,7 +4762,7 @@ fn drop_cap_fields(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cn crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:")); let style = field_or(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; - egui::ComboBox::from_id_salt("ps_dropcap_style") + craft_ui::ComboBox::from_id_salt("ps_dropcap_style") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) .width(160.0) .show_ui(ui, |ui| { @@ -4812,19 +4813,19 @@ fn nested_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, fn nested_style_row(lang: &str, ui: &mut egui::Ui, ns: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) { let mut changed = false; let style = ns["style"].as_str().unwrap_or("[None]").to_string(); - egui::ComboBox::from_id_salt(("ns_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(110.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt(("ns_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { for c in cnames { if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { ns["style"] = json!(c); changed = true; } } - }, - ); + }); let through = ns["through"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("ns_thr", i)) + craft_ui::ComboBox::from_id_salt(("ns_thr", i)) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) .width(70.0) .show_ui(ui, |ui| { @@ -4846,7 +4847,7 @@ fn nested_style_row(lang: &str, ui: &mut egui::Ui, ns: &mut Value, i: usize, cna count_value(ui, ns, &mut changed); } let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); - egui::ComboBox::from_id_salt(("ns_until", i)) + craft_ui::ComboBox::from_id_salt(("ns_until", i)) .selected_text(crate::rtl::widget( ui, if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, &kind)) } else { crate::i18n::tr(lang, &kind).to_owned() }, @@ -4910,17 +4911,17 @@ fn nested_line_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Va fn nested_line_style_row(lang: &str, ui: &mut egui::Ui, l: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) { let mut changed = false; let style = l["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("nls_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(130.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt(("nls_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(130.0) + .show_ui(ui, |ui| { for c in cnames { if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { l["style"] = json!(c); changed = true; } } - }, - ); + }); crate::rtl::label( ui, if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, "lines")) } else { crate::i18n::tr(lang, "for").to_owned() }, @@ -4953,7 +4954,7 @@ fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:")); let style = g["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("gs_style", i)) + craft_ui::ComboBox::from_id_salt(("gs_style", i)) .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) .width(110.0) .show_ui(ui, |ui| { @@ -5068,7 +5069,7 @@ fn span_columns(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: // "Span Columns" names both the section and this option, which some languages word differently. let kind_label = |k: &'static str| -> &'static str { crate::i18n::tr_context(lang, k, "paragraphLayout") }; let shown = kinds.iter().find(|k| same(&k.1, &cur)).map_or("", |k| kind_label(k.0)); - egui::ComboBox::from_id_salt("psspankind").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psspankind").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { for (label, v) in kinds { if ui.selectable_label(same(&v, &cur), crate::rtl::widget(ui, kind_label(label))).clicked() && !same(&v, &cur) { next = Some(v); @@ -5081,7 +5082,7 @@ fn span_columns(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: SpanColumns::Span(n) => { crate::rtl::label(ui, crate::i18n::tr(lang, "Columns:")); let shown = if n == 0 { crate::i18n::tr(lang, "All").to_string() } else { n.to_string() }; - egui::ComboBox::from_id_salt("psspann").selected_text(crate::rtl::widget(ui, shown)).width(80.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psspann").selected_text(crate::rtl::widget(ui, shown)).width(80.0).show_ui(ui, |ui| { if ui.selectable_label(n == 0, crate::rtl::widget(ui, crate::i18n::tr(lang, "All"))).clicked() { next = Some(SpanColumns::Span(0)); } @@ -5174,9 +5175,10 @@ impl ListUi<'_> { /// A character style for a bullet or number (`key`), [None] first. fn list_char_style(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, styles: &[String]) { let cur = para_value(d, pv, key).as_str().unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); - egui::ComboBox::from_id_salt(("pslistcs", key)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &cur))).width(200.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt(("pslistcs", key)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &cur))) + .width(200.0) + .show_ui(ui, |ui| { for n in std::iter::once(designcraft_doc::NO_CHAR_STYLE) .chain(styles.iter().map(String::as_str).filter(|n| *n != designcraft_doc::NO_CHAR_STYLE)) { @@ -5184,8 +5186,7 @@ fn list_char_style(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, ke para_set(d, key, json!(n)); } } - }, - ); + }); } /// The bullet character: common bullets, a field, and the font's glyphs in a grid to pick from. @@ -5244,7 +5245,7 @@ fn bullet_font(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, lu: &L let font = para_value(d, pv, "bulletFont").as_str().unwrap_or("").to_string(); let inherit = crate::i18n::tr(lang, "(Paragraph Font)"); crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); - egui::ComboBox::from_id_salt("psbulletfont").selected_text(if font.is_empty() { inherit } else { font.as_str() }).width(200.0).show_ui( + craft_ui::ComboBox::from_id_salt("psbulletfont").selected_text(if font.is_empty() { inherit } else { font.as_str() }).width(200.0).show_ui( ui, |ui| { if ui.selectable_label(font.is_empty(), inherit).clicked() { @@ -5263,7 +5264,7 @@ fn bullet_font(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, lu: &L crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:")); let style = para_value(d, pv, "bulletFontStyle").as_str().unwrap_or("").to_string(); ui.add_enabled_ui(!font.is_empty(), |ui| { - egui::ComboBox::from_id_salt("psbulletstyle").selected_text(style.as_str()).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psbulletstyle").selected_text(style.as_str()).width(200.0).show_ui(ui, |ui| { for st in lu.fonts.styles(&font) { if ui.selectable_label(st == style, &st).clicked() { para_set(d, "bulletFontStyle", json!(st)); @@ -5306,7 +5307,7 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val let list = para_value(d, pv, "listName").as_str().unwrap_or("").to_string(); // "" is the story's own list. let name = |n: &str| if n.is_empty() { crate::i18n::tr(lang, "[Default]").to_string() } else { n.to_string() }; - egui::ComboBox::from_id_salt("pslist").selected_text(name(&list)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("pslist").selected_text(name(&list)).width(200.0).show_ui(ui, |ui| { for n in std::iter::once("").chain(doc.settings.lists.iter().map(|l| l.name.as_str())) { if ui.selectable_label(n == list, name(n)).clicked() { para_set(d, "listName", json!(n)); @@ -5325,7 +5326,7 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val let start = para_value(d, pv, "startAt").as_u64(); ui.horizontal(|ui| { let shown = crate::i18n::tr(lang, if start.is_some() { "Start At" } else { "Continue from Previous Number" }); - egui::ComboBox::from_id_salt("psmode").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psmode").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { if ui .selectable_label(start.is_none(), crate::rtl::widget(ui, crate::i18n::tr(lang, "Continue from Previous Number"))) .clicked() @@ -5352,16 +5353,16 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val para_check(ui, d, pv, "restartNumbers", crate::i18n::tr(lang, "Restart Numbers at This Level After:")); let after = para_value(d, pv, "restartAfterLevel").as_u64().unwrap_or(0) as u32; let label = |n: u32| if n == 0 { crate::i18n::tr(lang, "Any Previous Level").to_string() } else { n.to_string() }; - egui::ComboBox::from_id_salt("psrestartafter").selected_text(crate::rtl::widget(ui, label(after))).width(140.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("psrestartafter") + .selected_text(crate::rtl::widget(ui, label(after))) + .width(140.0) + .show_ui(ui, |ui| { for n in 0..level { if ui.selectable_label(n == after, crate::rtl::widget(ui, label(n))).clicked() { para_set(d, "restartAfterLevel", json!(n)); } } - }, - ); + }); }); }); ui.end_row(); @@ -5497,7 +5498,7 @@ fn character_style_general( text_field_named(ui, d, "rename", 220.0, crate::i18n::tr(lang, "Style Name")); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Based On:")); - let combo = egui::ComboBox::from_id_salt("csbased") + let combo = craft_ui::ComboBox::from_id_salt("csbased") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, based))) .width(220.0) .show_ui(ui, |ui| { @@ -5656,7 +5657,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } @@ -5703,22 +5704,29 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); let shown = if fam.is_empty() { "—".to_string() } else { crate::panels::font_label(app, &menu, &fam) }; - egui::ComboBox::from_id_salt("psfam") + let picked = craft_ui::ComboBox::from_id_salt("psfam") .selected_text(shown) .width(200.0) .height(440.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() { - cf.set(d, "fontFamily", Value::Null); - } - if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) { - cf.set(d, "fontFamily", json!(f.family)); - if let Some(style) = f.style { - cf.set(d, "fontStyle", json!(style)); + .show_ui_with_header( + ui, + |ui| { + if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() { + cf.set(d, "fontFamily", Value::Null); } - } - }); + crate::panels::font_menu_header(app, ui, &menu, &fam, 200.0) + }, + crate::panels::font_menu_list, + ) + .inner + .flatten(); + if let Some(f) = picked { + cf.set(d, "fontFamily", json!(f.family)); + if let Some(style) = f.style { + cf.set(d, "fontStyle", json!(style)); + } + } ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:")); let sty = cf.get(d, "fontStyle").as_str().unwrap_or("").to_string(); @@ -5729,7 +5737,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo cf.set(d, "fontStyle", if s.trim().is_empty() { Value::Null } else { json!(s) }); } } else { - egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(sty.is_empty(), " ").clicked() { cf.set(d, "fontStyle", Value::Null); } @@ -5837,7 +5845,7 @@ fn advanced_character_formats(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: let labels: Vec = crate::panels::properties::DIGIT_OPTIONS.iter().map(|o| crate::panels::properties::digit_label(lang, *o)).collect(); let cur = cf.get(d, "digits"); let shown = crate::panels::properties::DIGIT_OPTIONS.iter().position(|o| json!(o.0) == cur).map_or(String::new(), |i| labels[i].clone()); - egui::ComboBox::from_id_salt(("cs", "digits")).selected_text(crate::rtl::widget(ui, &shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", "digits")).selected_text(crate::rtl::widget(ui, &shown)).width(200.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, "digits", Value::Null); } @@ -5913,14 +5921,18 @@ fn open_type_features(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFi crate::rtl::label(ui, crate::i18n::tr(lang, "Figure Style:")); let fig = otf::figures(&list); let shown = otf::FIGURES.iter().find(|f| f.0 == fig).map_or("", |f| f.1); - egui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(ui, |ui| { - for (id, label, ..) in otf::FIGURES { - if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() && otf::set_figures(&mut list, id) - { - changed = true; + craft_ui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui( + ui, + |ui| { + for (id, label, ..) in otf::FIGURES { + if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() + && otf::set_figures(&mut list, id) + { + changed = true; + } } - } - }); + }, + ); }); ui.add_space(4.0); crate::rtl::label(ui, crate::i18n::tr(lang, "Stylistic Sets")); @@ -5995,7 +6007,7 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE)); } let replace = d.s("replaceWith"); - egui::ComboBox::from_id_salt("del_char_replace") + craft_ui::ComboBox::from_id_salt("del_char_replace") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) .width(260.0) .show_ui(ui, |ui| { @@ -6010,7 +6022,7 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - egui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { d.fields.insert(key.into(), json!(v)); @@ -6351,7 +6363,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }; ui.horizontal(|ui| { let shown = RULES.iter().find(|r| r.0 == which).map_or(which, |r| r.1); - egui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui( + craft_ui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui( ui, |ui| { for (v, label) in RULES { @@ -6387,7 +6399,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("rule_width", which)) + craft_ui::ComboBox::from_id_salt(("rule_width", which)) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if column { "Column" } else { "Text" }))) .width(130.0) .show_ui(ui, |ui| { @@ -6794,7 +6806,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; - egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_empty(), auto).clicked() { cur.clear(); changed = true; @@ -6885,7 +6897,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, tr("Page Number:")); let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); - egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { for (v, l) in choices { if ui.selectable_label(r.page_number == v, tr(l)).clicked() { r.page_number = v.into(); @@ -6904,7 +6916,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); crate::rtl::label(ui, tr("Level:")); - egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { for l in 1..=9u8 { if ui.selectable_label(r.level == l, l.to_string()).clicked() { r.level = l; diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index 28005e7ac..191436fa4 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -130,7 +130,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if kind == "color" { caption(ui, lang, "Color Type:"); let cur = d.s("colorType"); - let combo = egui::ComboBox::from_id_salt("swatch_color_type") + let combo = craft_ui::ComboBox::from_id_salt("swatch_color_type") .selected_text(crate::rtl::widget(ui, tr(lang, if cur == "spot" { "Spot" } else { "Process" }))) .show_ui(ui, |ui| { for (v, l) in [("process", "Process"), ("spot", "Spot")] { @@ -149,9 +149,9 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { "gray" => "Gray", _ => "CMYK", }; - let combo = egui::ComboBox::from_id_salt("swatch_color_mode").selected_text(crate::rtl::widget(ui, tr(lang, label(&cur)))).show_ui( - ui, - |ui| { + let combo = craft_ui::ComboBox::from_id_salt("swatch_color_mode") + .selected_text(crate::rtl::widget(ui, tr(lang, label(&cur)))) + .show_ui(ui, |ui| { for m in ["cmyk", "rgb", "gray"] { if ui.selectable_label(cur == m, crate::rtl::widget(ui, tr(lang, label(m)))).clicked() && cur != m @@ -160,8 +160,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { set_color(&mut d.fields, c, Some(m)); } } - }, - ); + }); let _ = crate::widgets::name_combo(combo, tr(lang, "Color Mode")); ui.end_row(); match d.s("mode").as_str() { @@ -283,7 +282,7 @@ pub fn layer_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); caption(ui, lang, "Color:"); ui.horizontal(|ui| { - let combo = egui::ComboBox::from_id_salt("layer_color") + let combo = craft_ui::ComboBox::from_id_salt("layer_color") .selected_text(crate::rtl::widget(ui, tr(lang, named.unwrap_or("Custom")))) .width(140.0) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/dialogs/style_cjk.rs b/crates/ui-egui/src/dialogs/style_cjk.rs index 674cf44a7..acb6a6bc7 100644 --- a/crates/ui-egui/src/dialogs/style_cjk.rs +++ b/crates/ui-egui/src/dialogs/style_cjk.rs @@ -143,33 +143,40 @@ fn font_fields(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char }; crate::rtl::label(ui, tr(lang, "Font Family:")); ui.add_enabled_ui(enabled, |ui| { - egui::ComboBox::from_id_salt(("cjkfam", fam_key)) + let picked = craft_ui::ComboBox::from_id_salt(("cjkfam", fam_key)) .selected_text(shown) .width(200.0) .height(440.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - if cf.sparse && ui.selectable_label(raw.is_null(), " ").clicked() { - cf.set(d, fam_key, Value::Null); - cf.set(d, style_key, Value::Null); - } - if ui.selectable_label(!raw.is_null() && fam.is_empty(), crate::rtl::widget(ui, parent.as_str())).clicked() { - cf.set(d, fam_key, json!("")); - cf.set(d, style_key, json!("")); - } - if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) { - cf.set(d, fam_key, json!(f.family)); - if let Some(style) = f.style { - cf.set(d, style_key, json!(style)); + .show_ui_with_header( + ui, + |ui| { + if cf.sparse && ui.selectable_label(raw.is_null(), " ").clicked() { + cf.set(d, fam_key, Value::Null); + cf.set(d, style_key, Value::Null); } - } - }); + if ui.selectable_label(!raw.is_null() && fam.is_empty(), crate::rtl::widget(ui, parent.as_str())).clicked() { + cf.set(d, fam_key, json!("")); + cf.set(d, style_key, json!("")); + } + crate::panels::font_menu_header(app, ui, &menu, &fam, 200.0) + }, + crate::panels::font_menu_list, + ) + .inner + .flatten(); + if let Some(f) = picked { + cf.set(d, fam_key, json!(f.family)); + if let Some(style) = f.style { + cf.set(d, style_key, json!(style)); + } + } }); ui.end_row(); crate::rtl::label(ui, tr(lang, "Font Style:")); let sty = cf.get(d, style_key).as_str().unwrap_or("").to_string(); ui.add_enabled_ui(enabled && !fam.is_empty(), |ui| { - egui::ComboBox::from_id_salt(("cjksty", style_key)).selected_text(&sty).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cjksty", style_key)).selected_text(&sty).width(200.0).show_ui(ui, |ui| { for s in fonts.styles(&fam) { if ui.selectable_label(s == sty, &s).clicked() { cf.set(d, style_key, json!(s)); @@ -527,7 +534,7 @@ fn kenten(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField Some(false) => tr(lang, "None").to_string(), Some(true) => tr(lang, kenten_label(kind)).to_string(), }; - egui::ComboBox::from_id_salt("cjkkententype").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("cjkkententype").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(on.is_none(), " ").clicked() { for key in ["kenten", "kentenKind", "kentenCharacter"] { cf.set(d, key, Value::Null); @@ -654,7 +661,7 @@ fn para_value(d: &Dialog, pv: &Value, key: &str) -> Value { /// A paragraph attribute from `opts` (value, label); `current` is the value shown. fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, current: &str, opts: &[(&str, &str)]) { let shown = opts.iter().find(|o| o.0 == current).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cjkp", key)).selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cjkp", key)).selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(*v == current, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { d.fields.insert(format!("p.{key}"), json!(v)); @@ -731,7 +738,7 @@ fn japanese_composition(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char // `null`: no set given, so the composer's default rules apply. let name = para_value(d, pv, "kinsoku").get("name").and_then(Value::as_str).map(str::to_string); let shown = name.as_deref().map_or_else(|| crate::i18n::tr(lang, "Default Rules").to_string(), |n| kinsoku_label(lang, n)); - egui::ComboBox::from_id_salt("cjkkinsoku").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("cjkkinsoku").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { // `style.paragraph.edit` takes `null` as the default rules, also over a Based On set. if ui.selectable_label(name.is_none(), crate::rtl::widget(ui, crate::i18n::tr(lang, "Default Rules"))).clicked() { d.fields.insert("p.kinsoku".into(), Value::Null); @@ -780,7 +787,7 @@ fn japanese_composition(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char let sets = mojikumi_sets(doc, ¤t); let label = |l: &str| if l.is_empty() { crate::i18n::tr(lang, "None").to_string() } else { l.to_string() }; let shown = sets.iter().find(|s| s.0 == current).map_or_else(String::new, |s| label(s.1.as_str())); - egui::ComboBox::from_id_salt("cjkmojikumi").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("cjkmojikumi").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { for (v, l) in &sets { if ui.selectable_label(*v == current, crate::rtl::widget(ui, label(l.as_str()))).clicked() { d.fields.insert("p.mojikumi".into(), json!(v)); @@ -834,7 +841,7 @@ fn grid_settings(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) { grid("cjkgrid").show(ui, |ui| { crate::rtl::label(ui, tr(lang, "Grid Alignment:")); let shown = if on { REFERENCES.iter().find(|r| r.0 == reference).map_or("", |r| r.1) } else { "None" }; - egui::ComboBox::from_id_salt("cjkgridalign").selected_text(crate::rtl::widget(ui, tr(lang, shown))).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("cjkgridalign").selected_text(crate::rtl::widget(ui, tr(lang, shown))).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(!on, crate::rtl::widget(ui, tr(lang, "None"))).clicked() { set(d, GridAlign::None); } diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index bcfe4b26a..0fc2b4efc 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -250,7 +250,7 @@ fn swatch(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { .session .active() .map_or_else(Vec::new, |st| st.doc.swatches.iter().filter(|w| !w.hidden || w.name == cur).map(|w| w.name.clone()).collect()); - egui::ComboBox::from_id_salt(("fx_swatch", path.join("."))).selected_text(&cur).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("fx_swatch", path.join("."))).selected_text(&cur).width(150.0).show_ui(ui, |ui| { for n in names { if ui.selectable_label(n == cur, &n).clicked() { set(d, path, json!(n)); @@ -299,7 +299,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { grid("t").show(ui, |ui| { row(ui, "Mode:"); let cur = d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string(); - egui::ComboBox::from_id_salt("fx_blend").selected_text(tr(&cur)).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fx_blend").selected_text(tr(&cur)).width(150.0).show_ui(ui, |ui| { for m in designcraft_color::BlendMode::ALL { if ui.selectable_label(m.label() == cur, tr(m.label())).clicked() { d.fields.insert("blend".into(), json!(m.label())); @@ -367,7 +367,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() }; let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - egui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { for m in designcraft_color::BlendMode::ALL { if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { set(d, &["innerGlow", "mode"], json!(label(&m))); @@ -395,7 +395,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { ui.end_row(); row(ui, "Technique:"); let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["technique"].as_str()).unwrap_or("softer").to_string(); - egui::ComboBox::from_id_salt("fx_technique").selected_text(tr(if cur == "precise" { "Precise" } else { "Softer" })).show_ui( + craft_ui::ComboBox::from_id_salt("fx_technique").selected_text(tr(if cur == "precise" { "Precise" } else { "Softer" })).show_ui( ui, |ui| { for (v, l) in [("softer", "Softer"), ("precise", "Precise")] { @@ -409,7 +409,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { row(ui, "Source:"); let mut center = d.fields.get("fx").is_some_and(|f| f["innerGlow"]["center"].as_bool().unwrap_or(false)); let label = |c: bool| tr(if c { "Center" } else { "Edge" }); - egui::ComboBox::from_id_salt("fx_source").selected_text(label(center)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fx_source").selected_text(label(center)).show_ui(ui, |ui| { for c in [false, true] { if ui.selectable_value(&mut center, c, label(c)).clicked() { set(d, &["innerGlow", "center"], json!(c)); @@ -490,7 +490,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { "rounded" => "Rounded", _ => "Diffused", }; - egui::ComboBox::from_id_salt("fx_corners").selected_text(tr(shown)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fx_corners").selected_text(tr(shown)).show_ui(ui, |ui| { for (v, l) in [("sharp", "Sharp"), ("rounded", "Rounded"), ("diffused", "Diffused")] { if ui.selectable_label(cur == v, tr(l)).clicked() { set(d, &["featherOptions", "corners"], json!(v)); @@ -539,7 +539,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { row(ui, "Type:"); let mut radial = d.fields.get("fx").is_some_and(|f| f["gradientFeather"]["radial"].as_bool().unwrap_or(false)); let label = |r: bool| tr(if r { "Radial" } else { "Linear" }); - egui::ComboBox::from_id_salt("fx_gtype").selected_text(label(radial)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fx_gtype").selected_text(label(radial)).show_ui(ui, |ui| { for r in [false, true] { if ui.selectable_value(&mut radial, r, label(r)).clicked() { set(d, &["gradientFeather", "radial"], json!(r)); @@ -573,7 +573,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, tr("Settings for:")); // Stroke, Fill and Text levels aren't in the document model yet: the object only. ui.add_enabled_ui(false, |ui| { - egui::ComboBox::from_id_salt("fx_level").selected_text(tr("Object")).width(110.0).show_ui(ui, |_| {}); + craft_ui::ComboBox::from_id_salt("fx_level").selected_text(tr("Object")).width(110.0).show_ui(ui, |_| {}); }); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index f52adf38c..08b1a3e69 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -771,14 +771,17 @@ fn details(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) { row(ui, rtl, |ui| { cell(ui, rtl, tr("Width"), |ui| spin(ui, d, "width", COLUMN_W - 8.0, Step::Measure(1.0))); cell(ui, rtl, tr("Units"), |ui| { - egui::ComboBox::from_id_salt("nd_units").selected_text(crate::rtl::widget(ui, tr(u.label()))).width(COLUMN_W - 8.0).show_ui(ui, |ui| { - // InDesign's eight; a preset in another unit keeps it on the list. - for v in NEW_DOCUMENT_UNITS.into_iter().chain((!NEW_DOCUMENT_UNITS.contains(&u)).then_some(u)) { - if ui.selectable_label(v == u, crate::rtl::widget(ui, tr(v.label()))).clicked() && v != u { - set_units(d, v); + craft_ui::ComboBox::from_id_salt("nd_units").selected_text(crate::rtl::widget(ui, tr(u.label()))).width(COLUMN_W - 8.0).show_ui( + ui, + |ui| { + // InDesign's eight; a preset in another unit keeps it on the list. + for v in NEW_DOCUMENT_UNITS.into_iter().chain((!NEW_DOCUMENT_UNITS.contains(&u)).then_some(u)) { + if ui.selectable_label(v == u, crate::rtl::widget(ui, tr(v.label()))).clicked() && v != u { + set_units(d, v); + } } - } - }); + }, + ); }); }); row(ui, rtl, |ui| { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bf18de841..5f1b8829c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3,7 +3,7 @@ pub mod cell_stroke; pub mod conditions; mod font_menu; -pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body}; +pub use font_menu::{FONT_MENU_HEIGHT, FontMenu, FontPick, SAMPLES as FONT_SAMPLES, font_menu_header, font_menu_list}; pub mod datamerge; pub mod glyphs; pub mod interactive; @@ -383,7 +383,8 @@ pub fn swatch_picker( Some(resp) } -/// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu. +/// A font family combo box showing `current` by its menu name, with the Font menu +/// ([`font_menu_header`]) as its menu. /// Returns the font picked. pub fn font_combo( app: &mut DesignApp, @@ -394,15 +395,20 @@ pub fn font_combo( ) -> Option { let menu = font_menu(app); let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) }; - // A field and a plain popup rather than egui's combo box: its own scroll area would keep the - // menu at the size it last had (a family opening, a filter clearing, wouldn't show). let field = ui.push_id(id_salt, |ui| crate::widgets::dropdown(ui, &shown, width)).inner; - font_popup(app, &field, &menu, current) + font_popup(app, ui, &field, &menu, current) } -/// [`font_menu_body`] in a popup under `field` (a font field the caller draws), which screen -/// readers hear as a combo box with the current family as its value. Returns the font picked. -pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { +/// The Font menu ([`font_menu_header`]) in a menu under `field` (a font field the caller draws in +/// `ui`), which screen readers hear as a combo box with the current family as its value. Returns +/// the font picked. +pub fn font_popup( + app: &mut DesignApp, + ui: &egui::Ui, + field: &egui::Response, + menu: &[designcraft_fonts::FamilyInfo], + current: &str, +) -> Option { let shown = if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }; field.widget_info(|| { let mut info = egui::WidgetInfo::new(egui::Role::ComboBox); @@ -411,10 +417,12 @@ pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcra info.label = Some(crate::i18n::tr(&app.ui.language, "Font Family").to_owned()); info }); - egui::Popup::menu(field) + let width = field.rect.width(); + craft_ui::combo::Menu::new(ui, field) + .height(FONT_MENU_HEIGHT) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) - .and_then(|r| r.inner) + .show_ui_with_header(|ui| font_menu_header(app, ui, menu, current, width), font_menu_list) + .flatten() } /// The style a pick sets: the one picked, else the family's Regular (else its first). @@ -454,7 +462,7 @@ pub fn style_shown(current: &str) -> String { pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) { let styles = fonts(app).styles(family); let mut pick = None; - let combo = egui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| { for s in &styles { if ui.selectable_label(s == current, s).clicked() { pick = Some(s.clone()); @@ -472,7 +480,7 @@ pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, let mut pick = None; let shown = crate::i18n::style_name(&app.ui.language, current); let label = if overridden { format!("{shown}+") } else { shown.to_string() }; - let combo = egui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| { for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { if ui.selectable_label(n == current, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { pick = Some(n.clone()); diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs index b7f4eaa2e..2c99edc34 100644 --- a/crates/ui-egui/src/panels/cell_stroke.rs +++ b/crates/ui-egui/src/panels/cell_stroke.rs @@ -192,7 +192,7 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O ui.end_row(); caption(ui, tr(&lang, "Type:")); let shown = type_label(&lang, &v["type"]); - let combo = egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { for kind in TYPES { let text = tr(&lang, builtin(kind).label()); if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() { diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs index cbeb7f327..e42055aec 100644 --- a/crates/ui-egui/src/panels/datamerge.rs +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -197,7 +197,7 @@ fn select_source(app: &mut DesignApp) { fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str, name: &str) -> Option { let label = options.iter().find(|(value, _)| *value == current).map(|(_, text)| tr(lang, text)).unwrap_or(current); let mut picked = None; - let combo = egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { for (value, text) in options { if ui.selectable_label(*value == current, tr(lang, text)).clicked() { picked = Some((*value).to_string()); diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs index f07e8a810..1ce58506f 100644 --- a/crates/ui-egui/src/panels/font_menu.rs +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -297,13 +297,41 @@ fn clock(p: &egui::Painter, r: Rect, color: Color32) { /// one. A click on a family picks it (its Regular style), on one of its styles that style; the /// arrow opens a family, the star marks a favourite and ≈ shows similar fonts, all keeping the menu /// open (it closes on a click outside only). -pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], current: &str, width: f32) -> Option { +/// +/// [`font_menu_header`] lays out the search field and the filters, [`font_menu_list`] the rows +/// under them: the header and list of a [`craft_ui::combo::Menu`] or [`craft_ui::ComboBox`], so +/// only the rows scroll. Rows a field adds above the search (a blank "no font" row) go in the +/// header before [`font_menu_header`]. +pub struct FontMenu<'a> { + app: &'a mut DesignApp, + menu: &'a [FamilyInfo], + current: &'a str, + st: MenuState, + rows: Vec, + highlighted_row: Option, + loading: bool, + sample: String, + row_h: f32, + sample_size: f32, + down: bool, + up: bool, + escape: bool, + pick: Option, +} + +/// The Font menu's maximum height, its search field and filters included. +pub const FONT_MENU_HEIGHT: f32 = 500.0; + +const STATE_ID: &str = "font_menu_state"; + +/// The Font menu's search field and filters, for the header of a menu whose list is +/// [`font_menu_list`]. See [`FontMenu`]. +pub fn font_menu_header<'a>(app: &'a mut DesignApp, ui: &mut Ui, menu: &'a [FamilyInfo], current: &'a str, width: f32) -> FontMenu<'a> { let lang = app.ui.language.clone(); let tr = |s: &'static str| crate::i18n::tr(&lang, s); let (fonts, scope) = (super::fonts(app), super::font_scope(app)); - let english = app.session.prefs.show_font_names_in_english; - let mut favs = app.session.prefs.favorite_fonts.clone(); - let state_id = egui::Id::unique("font_menu_state"); + let favs = app.session.prefs.favorite_fonts.clone(); + let state_id = egui::Id::unique(STATE_ID); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); let frame = ui.ctx().cumulative_frame_nr(); if st.frame.is_none_or(|f| f.saturating_add(1) < frame) { @@ -315,7 +343,6 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur let t = Tokens::get(ui.ctx()); ui.set_width(width.max(560.0)); let mut pick: Option = None; - let mut favs_changed = false; // The keys the menu uses are its own: a dialog under it never sees Return or Escape. let (down, up, enter, escape) = ui.input_mut(|i| { let mut key = |k| i.consume_key(egui::Modifiers::NONE, k); @@ -506,125 +533,126 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur }; } + FontMenu { app, menu, current, st, rows, highlighted_row, loading, sample, row_h, sample_size, down, up, escape, pick } +} + +/// The Font menu's rows, under [`font_menu_header`]. Returns the font picked. +pub fn font_menu_list(ui: &mut Ui, m: FontMenu<'_>) -> Option { + let FontMenu { app, menu, current, mut st, rows, highlighted_row, loading, sample, row_h, sample_size, down, up, escape, mut pick } = m; + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + let (fonts, scope) = (super::fonts(app), super::font_scope(app)); + let english = app.session.prefs.show_font_names_in_english; + let mut favs = app.session.prefs.favorite_fonts.clone(); + let state_id = egui::Id::unique(STATE_ID); + let t = Tokens::get(ui.ctx()); + let mut favs_changed = false; let mut toggle_expand = None; let mut toggle_fav = None; let mut similar = None; - // A popup only offers last frame's height: ask for the rows' own (a family just opened, a - // filter just cleared), up to the list's limit. - let list_h: f32 = rows - .iter() - .map(|r| match r { - Row::Family { .. } => row_h + ui.spacing().item_spacing.y, - Row::Style { .. } => row_h.min(30.0) + ui.spacing().item_spacing.y, - Row::Separator => 8.0, - }) - .sum(); - egui::ScrollArea::vertical().max_height(420.0).min_scrolled_height(list_h.clamp(24.0, 420.0)).auto_shrink([false, true]).show(ui, |ui| { - if loading { - crate::rtl::label(ui, egui::RichText::new(tr("Loading fonts…")).color(t.text_dim)); - } else if rows.is_empty() { - crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim)); - } - for (i, row) in rows.iter().enumerate() { - let (family, style, recent) = match row { - Row::Separator => { - ui.separator(); - continue; - } - Row::Family { info, recent } => (info, None, *recent), - Row::Style { family, style } => { - let Some(info) = menu.iter().find(|f| f.family == *family) else { continue }; - (info, Some(style.as_str()), false) - } - }; - let f = &family.family; - let (r, mut resp) = - ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click()); - let highlighted = highlighted_row == Some(i); - if highlighted && (down || up) { - ui.scroll_to_rect(r, None); - } - if !ui.is_rect_visible(r) { + if loading { + crate::rtl::label(ui, egui::RichText::new(tr("Loading fonts…")).color(t.text_dim)); + } else if rows.is_empty() { + crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim)); + } + for (i, row) in rows.iter().enumerate() { + let (family, style, recent) = match row { + Row::Separator => { + ui.separator(); continue; } - let label = match style { - Some(s) => s.to_string(), - None => family.label(english).to_string(), - }; - let is_current = style.is_none() && f == current; - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label)); - if style.is_none() && label != *f { - resp = resp.on_hover_text(f); + Row::Family { info, recent } => (info, None, *recent), + Row::Style { family, style } => { + let Some(info) = menu.iter().find(|f| f.family == *family) else { continue }; + (info, Some(style.as_str()), false) } - // Over the row or one of its buttons (the star, the arrow, ≈ are on top of it). - let hovered = resp.contains_pointer(); - if is_current { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if highlighted || hovered { - ui.painter().rect_filled(r, 0.0, t.hover); + }; + let f = &family.family; + let (r, mut resp) = ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click()); + let highlighted = highlighted_row == Some(i); + if highlighted && (down || up) { + ui.scroll_to_rect(r, None); + } + if !ui.is_rect_visible(r) { + continue; + } + let label = match style { + Some(s) => s.to_string(), + None => family.label(english).to_string(), + }; + let is_current = style.is_none() && f == current; + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label)); + if style.is_none() && label != *f { + resp = resp.on_hover_text(f); + } + // Over the row or one of its buttons (the star, the arrow, ≈ are on top of it). + let hovered = resp.contains_pointer(); + if is_current { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if highlighted || hovered { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let p = ui.painter_at(r); + let m = meta(ui, &fonts, scope, f); + let cy = r.center().y; + let mut handled = false; + if style.is_none() { + // Favourite star. + let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0)); + let fav = favs.contains(f); + let sr = ui.interact(star, ui.scope_id().with(("fav", f, recent)), Sense::click()); + paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); + if sr.clicked() { + toggle_fav = Some(f.clone()); + handled = true; } - let p = ui.painter_at(r); - let m = meta(ui, &fonts, scope, f); - let cy = r.center().y; - let mut handled = false; - if style.is_none() { - // Favourite star. - let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0)); - let fav = favs.contains(f); - let sr = ui.interact(star, ui.scope_id().with(("fav", f, recent)), Sense::click()); - paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); - if sr.clicked() { - toggle_fav = Some(f.clone()); + // The arrow opens a family of several styles. + if m.styles.len() > 1 && !recent { + let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0)); + let ar = ui.interact(arrow, ui.scope_id().with(("open", f)), Sense::click()); + ar.widget_info(|| { + egui::WidgetInfo::labeled(egui::Role::Button, true, tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" })) + }); + let open = st.expanded.contains(f); + crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon); + if ar.clicked() { + toggle_expand = Some(f.clone()); handled = true; } - // The arrow opens a family of several styles. - if m.styles.len() > 1 && !recent { - let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0)); - let ar = ui.interact(arrow, ui.scope_id().with(("open", f)), Sense::click()); - ar.widget_info(|| { - egui::WidgetInfo::labeled(egui::Role::Button, true, tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" })) - }); - let open = st.expanded.contains(f); - crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon); - if ar.clicked() { - toggle_expand = Some(f.clone()); - handled = true; - } - } - } - // Name (and how many styles), then the sample in the face, then the format. - let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 }; - let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() }; - p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text); - let preview_x = r.min.x + (r.width() * 0.42).max(190.0); - let icons_x = r.max.x - 52.0; - let face_style = style.map_or_else(|| regular(&m.styles), str::to_string); - let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text); - let size = tex.size_vec2() / ui.ctx().pixels_per_point(); - let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size); - let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y)); - ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - // Show Similar Fonts (on hover), and the format. - if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) { - let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0)); - let sr = ui.interact(sim, ui.scope_id().with(("similar", f, recent)), Sense::click()); - sr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Show Similar Fonts"))); - p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon }); - if sr.on_hover_text(tr("Show Similar Fonts")).clicked() { - similar = Some(f.clone()); - handled = true; - } - } - if style.is_none() { - let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0)); - paint_format(&p, badge, m.kind.format, t.text_dim, t.accent); - ui.interact(badge, ui.scope_id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); - } - if resp.clicked() && !handled { - pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) }); } } - }); + // Name (and how many styles), then the sample in the face, then the format. + let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 }; + let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() }; + p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text); + let preview_x = r.min.x + (r.width() * 0.42).max(190.0); + let icons_x = r.max.x - 52.0; + let face_style = style.map_or_else(|| regular(&m.styles), str::to_string); + let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text); + let size = tex.size_vec2() / ui.ctx().pixels_per_point(); + let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size); + let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y)); + ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + // Show Similar Fonts (on hover), and the format. + if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) { + let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0)); + let sr = ui.interact(sim, ui.scope_id().with(("similar", f, recent)), Sense::click()); + sr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Show Similar Fonts"))); + p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon }); + if sr.on_hover_text(tr("Show Similar Fonts")).clicked() { + similar = Some(f.clone()); + handled = true; + } + } + if style.is_none() { + let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0)); + paint_format(&p, badge, m.kind.format, t.text_dim, t.accent); + ui.interact(badge, ui.scope_id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); + } + if resp.clicked() && !handled { + pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) }); + } + } if let Some(f) = toggle_expand && !st.expanded.remove(&f) { diff --git a/crates/ui-egui/src/panels/font_menu_ui_tests.rs b/crates/ui-egui/src/panels/font_menu_ui_tests.rs index daad2d632..8680ccfdb 100644 --- a/crates/ui-egui/src/panels/font_menu_ui_tests.rs +++ b/crates/ui-egui/src/panels/font_menu_ui_tests.rs @@ -301,3 +301,66 @@ fn font_fields_in_panels_and_dialogs_search() { assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open"); } } + +/// The font rows the open menu shows under its search field `search`: how many, and the bottom +/// of the lowest. +fn rows_shown(h: &Harness<'static, Window>, search: egui::Rect) -> (usize, f32) { + let families: Vec = crate::panels::font_menu(&h.state().app).iter().map(|f| f.label(true).to_string()).collect(); + let rows: Vec = h + .query_all_by(|n| n.role() == egui::accesskit::Role::Button && n.label().is_some_and(|l| families.contains(&l))) + .map(|n| n.rect()) + .filter(|r| r.min.y > search.max.y) + .collect(); + (rows.len(), rows.iter().map(|r| r.max.y).fold(f32::MIN, f32::max)) +} + +/// Opens the font menu of the field at `at`, searches for a font that doesn't exist and clears +/// the search: the menu grows back to the rows it showed, with the search field where it was. +/// The menu lists every family by its English name: the test UI has no CJK font for a system +/// CJK family's native name. +fn menu_regrows_after_a_search_with_no_matches(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) { + h.state_mut().app.session.prefs.show_font_names_in_english = true; + click_at(h, at.center()); + h.run_steps(4); + let focused = |h: &Harness<'static, Window>| h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect()); + let field = focused(h).unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused")); + let (count, bottom) = rows_shown(h, field); + assert!(count > 1, "{what}: the menu lists the fonts: {count}"); + + let query = "zzqqxxnomatch"; + h.event(egui::Event::Text(query.into())); + h.run_steps(4); + assert!(h.query_by_label("No fonts match").is_some(), "{what}: nothing matches"); + assert_eq!(rows_shown(h, field).0, 0, "{what}: no rows while nothing matches"); + + for _ in query.chars() { + h.key_press(egui::Key::Backspace); + } + h.run_steps(4); + assert_eq!(search(h), "", "{what}: the search is cleared"); + let again = focused(h).unwrap_or_else(|| panic!("{what}: the menu is still open")); + assert!((again.min.y - field.min.y).abs() < 0.5, "{what}: the search field stays at {}, not {}", field.min.y, again.min.y); + let (count_again, bottom_again) = rows_shown(h, again); + assert_eq!(count_again, count, "{what}: the menu shows as many rows as before the search"); + assert!((bottom_again - bottom).abs() < 1.0, "{what}: the menu grows back to {bottom}, not {bottom_again}"); +} + +#[test] +fn the_font_field_menu_grows_back_after_a_search_with_no_matches() { + let (mut h, _) = typing(); + let at = field(&h, designcraft_fonts::DEFAULT_FAMILY); + menu_regrows_after_a_search_with_no_matches(&mut h, at, "Control bar"); +} + +/// Paragraph Style Options › Basic Character Formats' Font Family combo box. The CJK style +/// fields' combo box hosts the same menu the same way. +#[test] +fn the_style_dialog_font_menu_grows_back_after_a_search_with_no_matches() { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let section = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"}); + h.state_mut().app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", section)); + h.run_steps(4); + let at = *fields(&h, designcraft_fonts::DEFAULT_FAMILY).first().expect("the dialog's font field is shown"); + menu_regrows_after_a_search_with_no_matches(&mut h, at, "Paragraph Style Options"); +} diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7d1da4ea3..965509ff4 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -38,7 +38,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { st.style = f.style.clone().or_else(|| db.styles(&f.family).into_iter().next()).unwrap_or_else(|| "Regular".into()); st.family = f.family; } - let combo = egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { for s in db.styles(&st.family) { if ui.selectable_label(s == st.style, &s).clicked() { st.style = s; diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 7ca6cf595..5c7ae92ed 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -365,7 +365,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { let mut pick = None; ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Release")); - let combo = egui::ComboBox::from_id_salt("button_action") + let combo = craft_ui::ComboBox::from_id_salt("button_action") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, labels.iter().find(|l| l.0 == kind).map_or("", |l| l.1)))) .show_ui(ui, |ui| { for (k, l) in labels { @@ -426,7 +426,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = ff.as_ref().map(|f| f.kind); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Form Field")); - let combo = egui::ComboBox::from_id_salt("form_kind") + let combo = craft_ui::ComboBox::from_id_salt("form_kind") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kinds.iter().find(|k| k.0 == cur).map_or("", |k| k.1)))) .show_ui(ui, |ui| { for (k, l) in kinds { @@ -539,7 +539,7 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); }); }); - let combo = egui::ComboBox::from_id_salt("liquid_rule") + let combo = craft_ui::ComboBox::from_id_salt("liquid_rule") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) .width(190.0) .show_ui(ui, |ui| { @@ -669,7 +669,7 @@ pub fn transitions(app: &mut DesignApp, ui: &mut egui::Ui) { use designcraft_doc::TransitionKind as K; crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Spread"), si + 1)).strong()); let mut kind = cur.map(|c| c.kind); - let combo = egui::ComboBox::from_id_salt("transition_kind") + let combo = craft_ui::ComboBox::from_id_salt("transition_kind") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kind.map_or("None", K::label)))) .show_ui(ui, |ui| { ui.selectable_value(&mut kind, None, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "None"))); diff --git a/crates/ui-egui/src/panels/properties/object_panels.rs b/crates/ui-egui/src/panels/properties/object_panels.rs index d8ad4743b..6de8daaaf 100644 --- a/crates/ui-egui/src/panels/properties/object_panels.rs +++ b/crates/ui-egui/src/panels/properties/object_panels.rs @@ -66,7 +66,7 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { StrokeType::Style { name } => name.clone(), k => k.label().to_string(), }; - let combo = egui::ComboBox::from_id_salt("stype") + let combo = craft_ui::ComboBox::from_id_salt("stype") .selected_text(crate::rtl::widget( ui, if matches!(st.kind, StrokeType::Style { .. }) { &shown } else { crate::i18n::tr(&app.ui.language, &shown) }, @@ -106,7 +106,7 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); for (key, label, cur, flip) in [("start", "Start/End:", st.start, true), ("end", "", st.end, false)] { caption(ui, crate::i18n::tr(&app.ui.language, label)); - let combo = egui::ComboBox::from_id_salt(key) + let combo = craft_ui::ComboBox::from_id_salt(key) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label()))) .width(140.0) .show_ui(ui, |ui| { @@ -305,7 +305,7 @@ fn wrap_combo( ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, caption_text)); let shown = opts.iter().chain(shown_only).find(|o| o.0 == cur).map_or(cur, |o| o.1).to_string(); - let combo = egui::ComboBox::from_id_salt(salt) + let combo = craft_ui::ComboBox::from_id_salt(salt) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &shown))) .width(170.0) .show_ui(ui, |ui| { @@ -468,7 +468,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { B::Luminosity, ]; ui.horizontal(|ui| { - let combo = egui::ComboBox::from_id_salt("blend") + let combo = craft_ui::ComboBox::from_id_salt("blend") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, it.blend.label()))) .width(130.0) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 229d11a23..96c0a7e69 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -179,7 +179,7 @@ pub(super) fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::FontId::proportional(11.5), t.text, ); - if let Some(f) = crate::panels::font_popup(app, &resp, &menu, &fam) { + if let Some(f) = crate::panels::font_popup(app, ui, &resp, &menu, &fam) { crate::panels::apply_font(app, &f); } ui.add_space(1.0); diff --git a/crates/ui-egui/src/panels/properties/typography.rs b/crates/ui-egui/src/panels/properties/typography.rs index b349e11a1..ce823f5db 100644 --- a/crates/ui-egui/src/panels/properties/typography.rs +++ b/crates/ui-egui/src/panels/properties/typography.rs @@ -16,7 +16,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Language")); - let combo = egui::ComboBox::from_id_salt("char_language") + let combo = craft_ui::ComboBox::from_id_salt("char_language") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &lang))) .width(170.0) .show_ui(ui, |ui| language_menu(app, ui, &lang)); @@ -30,7 +30,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { .iter() .find(|o| o.0 == digits) .map_or_else(|| digit_label(&app.ui.language, DIGIT_OPTIONS[0]), |o| digit_label(&app.ui.language, *o)); - let combo = egui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| { for o in DIGIT_OPTIONS { if ui.selectable_label(o.0 == digits, crate::rtl::widget(ui, digit_label(&app.ui.language, o))).clicked() { let _ = app.run("type.char", json!({"attrs": {"digits": o.0}})); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 08b41ac87..1c6f41b27 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -401,7 +401,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let combo = egui::ComboBox::from_id_salt("color_mode") + let combo = craft_ui::ComboBox::from_id_salt("color_mode") .width(64.0) .selected_text(match mode { ColorMode::Cmyk => "CMYK", @@ -741,7 +741,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Type:"), 52.0); let shown = if is_gradient { crate::i18n::tr(&lang, g.kind.label()) } else { "" }; let combo = - egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| { for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { if ui .selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k))) diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 5bda05989..90cf274ea 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -69,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { for (label, kind, names, cur) in [("Table Style", "table", tnames, tcur), ("Cell Style", "cell", cnames, ccur)] { ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, label)); - let combo = egui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(*n == cur, n).clicked() { run(app, &format!("style.{kind}.apply"), json!({"name": n, "table": tid})); @@ -126,7 +126,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let row = rows.get(r0).cloned().unwrap_or(Value::Null); let exactly = row["mode"] == "exactly"; caption(ui, crate::i18n::tr(&app.ui.language, "Row Height")); - let combo = egui::ComboBox::from_id_salt("tp_rowmode") + let combo = craft_ui::ComboBox::from_id_salt("tp_rowmode") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if exactly { "Exactly" } else { "At Least" }))) .width(70.0) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index 899b9fbed..ea96f9f60 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -105,7 +105,7 @@ fn text(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) { fn style_menu(ui: &mut Ui, d: &mut Dialog, key: &str) { let current: NumberStyle = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let shown = STYLES.iter().find(|(s, _)| *s == current).map_or(STYLES[0].1, |(_, l)| l); - egui::ComboBox::from_id_salt(("section_style", key)).selected_text(shown).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("section_style", key)).selected_text(shown).width(150.0).show_ui(ui, |ui| { for (style, label) in STYLES { if ui.selectable_label(style == current, label).clicked() { d.fields.insert(key.into(), json!(style)); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 88c152f5b..4411d93dd 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -145,7 +145,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let menu = panels::font_menu(app); let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) }; let field = widgets::dropdown(ui, &shown, 150.0); - if let Some(f) = panels::font_popup(app, &field, &menu, &fam) { + if let Some(f) = panels::font_popup(app, ui, &field, &menu, &fam) { panels::apply_font(app, &f); } let styles = fonts.styles(&fam); diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 41509a770..6dc2a3330 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,7 +1,7 @@ # Shared PDF backend DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with -`hayro-cmap` and `hayro-postscript`) to craft-libs revision `b926b5c03e256c86ca10934f58d03f628810b5b7`. `craft-ui`, +`hayro-cmap` and `hayro-postscript`) to craft-libs revision `92d392cff132eaf53e13afff77f38b5c11b73aa0`. `craft-ui`, `craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change. ## Writing diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index b39c2538c..2f33f3109 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -5,7 +5,7 @@ use std::process::Command; use serde_json::Value; const REPO: &str = "https://github.com/storytold/craft-libs"; -const REV: &str = "b926b5c03e256c86ca10934f58d03f628810b5b7"; +const REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0"; const PACKAGES: &[(&str, &str)] = &[ ("craft-pdf", "0.1.0"), ("krilla", "0.8.2"), From d7e57d20161b716b91cedcb63f26c3ee9ac5c007 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:18 +0000 Subject: [PATCH 12/16] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2e..a4a14bc64 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index 5c1e2f1c9..28cad5b06 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From 1db1635a8831d2a890fd09f13fd4699b6eaf0c45 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:36:35 +0100 Subject: [PATCH 13/16] Ask the composition cache once per story and page in PDF export The PDF exporter called Cache::get twice for every text frame it drew. Each call rebuilds the cache signature of the whole story, which looks up every frame of the story with Document::find (a linear walk over all spreads), and the anchored-frame placements walk them again. For a story threaded through many frames this is O(frames^2 x items) per export. The document doesn't change during an export, so the exporter now keeps the composition it got for each (story, page) and reuses it. The cache still decides what to compose; the output is byte-identical. Add examples/pdf_export.rs to designcraft-cli: File > Export PDF through a headless session on the `designcraft-cli perf` stress document (200 pages, 1M characters in 40 threaded stories, 500 placed images). The stress document generator moves unchanged from src/perf.rs to a small library module (src/stress.rs) that the binary and the example share. Ryzen 16 threads, Linux, 12 runs each, interleaved: main median 3485 ms, min 3427 ms patched median 2016 ms, min 1656 ms (-42 % median, -52 % min) The exported PDF is byte-identical (12,075,744 bytes). Co-Authored-By: Claude Opus 5.5 --- apps/designcraft-cli/examples/pdf_export.rs | 64 +++++ apps/designcraft-cli/src/lib.rs | 5 + apps/designcraft-cli/src/perf.rs | 243 +------------------ apps/designcraft-cli/src/stress.rs | 244 ++++++++++++++++++++ crates/pdf/src/export.rs | 3 + crates/pdf/src/export/booklet.rs | 1 + crates/pdf/src/export/images.rs | 1 + crates/pdf/src/export/pipeline.rs | 1 + crates/pdf/src/export/walk.rs | 10 +- crates/pdf/src/tests.rs | 27 +++ 10 files changed, 358 insertions(+), 241 deletions(-) create mode 100644 apps/designcraft-cli/examples/pdf_export.rs create mode 100644 apps/designcraft-cli/src/lib.rs create mode 100644 apps/designcraft-cli/src/stress.rs diff --git a/apps/designcraft-cli/examples/pdf_export.rs b/apps/designcraft-cli/examples/pdf_export.rs new file mode 100644 index 000000000..c427741a2 --- /dev/null +++ b/apps/designcraft-cli/examples/pdf_export.rs @@ -0,0 +1,64 @@ +//! File › Export PDF on the `designcraft-cli perf` stress document, timed. +//! +//! ```text +//! cargo run --release -p designcraft-cli --example pdf_export -- [--pages N] [--runs N] [--out FILE.pdf] +//! ``` +//! +//! Builds the synthetic document (200 pages, 1M characters in threaded stories, 500 placed +//! images, gradients, a drop shadow per spread), opens it in a headless session and runs +//! `file.exportPdf` on all pages: one warm-up export (composition), then `--runs` timed ones. +//! Prints each run, the median and the minimum. `--out` keeps the last PDF, written with a fixed +//! creation date so two builds can be compared byte for byte. + +use std::time::Instant; + +use designcraft_cli::stress; + +fn main() -> Result<(), String> { + let mut spec = stress::Spec::default(); + let mut runs = 7usize; + let mut out: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let mut num = || args.next().and_then(|v| v.parse::().ok()).ok_or(format!("{a} needs a number")); + match a.as_str() { + "--pages" => spec.pages = num()?, + "--runs" => runs = num()?.max(1), + "--out" => out = Some(args.next().ok_or("--out needs a file")?), + other => return Err(format!("unknown option `{other}`")), + } + } + let doc = stress::synthetic(&spec)?; + let bytes = designcraft_format::save(&doc).map_err(|e| e.to_string())?; + let dir = std::env::temp_dir().join(format!("designcraft-pdf-bench-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let file = dir.join("stress.designcraft"); + let keep = out.is_some(); + let pdf = out.map_or_else(|| dir.join("stress.pdf"), std::path::PathBuf::from); + std::fs::write(&file, bytes).map_err(|e| e.to_string())?; + let mut s = designcraft_engine::Session::new(); + s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; + let params = serde_json::json!({"path": pdf, "created": 1_700_000_000}); + let mut export = || -> Result { + let t = Instant::now(); + s.execute("file.exportPdf", ¶ms).map_err(|e| e.to_string())?; + Ok(t.elapsed().as_secs_f64() * 1000.0) + }; + let warm = export()?; + println!("{} pages, warm-up export (with composition) {warm:.1} ms", doc.page_count()); + let mut ms = Vec::with_capacity(runs); + for i in 0..runs { + let t = export()?; + println!("run {}: {t:.1} ms", i + 1); + ms.push(t); + } + ms.sort_by(f64::total_cmp); + let size = std::fs::metadata(&pdf).map_or(0, |m| m.len()); + println!("median {:.1} ms, min {:.1} ms, {} kB", ms[ms.len() / 2], ms[0], size / 1024); + let _ = std::fs::remove_file(&file); + if !keep { + let _ = std::fs::remove_file(&pdf); + } + let _ = std::fs::remove_dir(&dir); + Ok(()) +} diff --git a/apps/designcraft-cli/src/lib.rs b/apps/designcraft-cli/src/lib.rs new file mode 100644 index 000000000..f63ceb415 --- /dev/null +++ b/apps/designcraft-cli/src/lib.rs @@ -0,0 +1,5 @@ +//! Headless DesignCraft's shared parts: the synthetic stress document of `designcraft-cli perf`, +//! which the PDF export benchmark (`examples/pdf_export.rs`) exports too. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod stress; diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 6f80cb356..fbc7d00d9 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -10,253 +10,18 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Instant; -use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; use designcraft_compose::Cache; -use designcraft_doc::build::NewDocument; -use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId}; -use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_doc::{Document, SpreadRef, StoryId}; +use designcraft_geom::{Affine, Rect, Vec2}; use designcraft_render::{Placed, RenderOptions, Renderer}; +use designcraft_cli::stress::{Spec, synthetic}; + /// Device pixels per point at 100% (HiDPI canvas). const HIDPI: f64 = 2.0; const W: u32 = 2880; const H: u32 = 1800; -/// Deterministic xorshift in 0..1. -struct Rng(u64); -impl Rng { - fn next(&mut self) -> f64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - (self.0 % 1_000_000) as f64 / 1_000_000.0 - } - fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { - v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] - } -} - -const WORDS: &[&str] = &[ - "the", - "grid", - "page", - "margin", - "column", - "rhythm", - "baseline", - "reader", - "story", - "frame", - "quiet", - "layout", - "type", - "paragraph", - "composer", - "weighs", - "every", - "line", - "break", - "texture", - "even", - "hyphen", - "image", - "spread", - "pulse", - "caption", - "colour", - "swatch", - "ink", - "tint", - "gradient", - "style", - "voice", - "printer", - "proof", - "bleed", - "a", - "of", - "and", - "to", - "in", - "with", - "for", - "is", - "on", - "that", - "by", - "it", - "as", - "careful", - "measure", - "leading", - "kerning", - "tracking", - "glyph", - "serif", - "italic", - "heading", - "running", - "folio", -]; - -/// `chars` characters of original filler text in paragraphs of ~400 characters. -fn filler(r: &mut Rng, chars: usize) -> String { - let mut s = String::with_capacity(chars + 16); - let mut para = 0; - while s.len() < chars { - let w = r.pick(WORDS); - if para == 0 { - let mut c = w.chars(); - if let Some(f) = c.next() { - s.extend(f.to_uppercase()); - s.push_str(c.as_str()); - } - } else { - s.push_str(w); - } - para += w.len() + 1; - if para > 400 { - s.push_str(".\n"); - para = 0; - } else { - s.push(' '); - } - } - s -} - -/// Procedural RGB image, PNG-encoded. -fn art_png(w: u32, h: u32, seed: f32) -> Vec { - let mut px = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); - let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); - px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); - } - } - designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() -} - -/// Parameters of the synthetic stress document. -pub struct Spec { - pub pages: usize, - pub frames_per_page: usize, - pub frames_per_story: usize, - pub chars: usize, - pub images: usize, - pub assets: usize, -} - -impl Default for Spec { - fn default() -> Self { - Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } - } -} - -/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text -/// frames, and a strip of placed images (one with a drop shadow per spread). -pub fn synthetic(spec: &Spec) -> Result { - let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); - let lid = d.default_layer(); - d.swatches.push(Swatch { - name: "Perf Gradient".into(), - value: SwatchValue::Gradient { - gradient: Gradient { - kind: GradientKind::Linear, - stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))], - }, - }, - locked: false, - named: true, - hidden: false, - }); - let mut rng = Rng(0x5eed); - let (aw, ah) = (800u32, 600u32); - let assets: Vec = (0..spec.assets.max(1)) - .map(|k| { - let id = AssetId(d.alloc()); - let data = art_png(aw, ah, k as f32 * 0.37); - d.assets.insert( - id, - Arc::new(Asset { - id, - name: format!("art-{k}.png"), - mime: "image/png".into(), - data: Arc::new(data), - pixels: Some((aw, ah)), - ..Default::default() - }), - ); - id - }) - .collect(); - let total_frames = spec.pages * spec.frames_per_page; - let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); - let per_story = spec.chars / stories.max(1); - let rows = spec.frames_per_page.div_ceil(2); - let (top, strip) = (48.0, 130.0); - let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; - let mut prev: Option = None; - let mut n_frame = 0usize; - let mut placed_images = 0usize; - for abs in 0..spec.pages { - let Some((si, pi)) = d.page_loc(abs) else { break }; - let sr = SpreadRef::Doc(si); - let x0 = d.spreads[si].pages[pi].x; - // Gradient band. - let id = ItemId(d.alloc()); - let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); - band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; - band.fill.gradient_angle = Some(rng.next() * 90.0); - d.insert_item(sr, band, None).map_err(|e| e.to_string())?; - // Text frames. - for k in 0..spec.frames_per_page { - let (c, r) = (k % 2, k / 2); - let fx = x0 + 36.0 + c as f64 * 280.0; - let fy = top + r as f64 * row_h; - let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); - let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); - let text = if first { filler(&mut rng, per_story) } else { String::new() }; - let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?; - if let (false, Some(p)) = (first, prev) { - d.thread(p, fid).map_err(|e| e.to_string())?; - } - prev = Some(fid); - n_frame += 1; - } - // Images: spread the requested count evenly over the pages. - let want = (spec.images * (abs + 1)) / spec.pages - placed_images; - for k in 0..want { - let w = 540.0 / want as f64; - let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); - let asset = assets[(placed_images + k) % assets.len()]; - let id = ItemId(d.alloc()); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - let (nw, nh) = (aw as f64, ah as f64); - let s = (r.width() / nw).max(r.height() / nh); - it.content = Content::Graphic(Graphic { - layer_options: None, - asset, - size: (nw, nh), - xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), - clip: None, - auto_fit: designcraft_doc::Fitting::FillProportionally, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - if k == 0 && pi == 0 { - it.effects.drop_shadow.on = true; - } - d.insert_item(sr, it, None).map_err(|e| e.to_string())?; - } - placed_images += want; - } - Ok(d) -} - fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { let mut v: Vec = (0..runs.max(1)) .map(|_| { diff --git a/apps/designcraft-cli/src/stress.rs b/apps/designcraft-cli/src/stress.rs new file mode 100644 index 000000000..c9d5b3015 --- /dev/null +++ b/apps/designcraft-cli/src/stress.rs @@ -0,0 +1,244 @@ +//! The synthetic stress document `designcraft-cli perf` measures, also used by the PDF export +//! benchmark (`examples/pdf_export.rs`). + +use std::sync::Arc; + +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } + fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { + v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] + } +} + +const WORDS: &[&str] = &[ + "the", + "grid", + "page", + "margin", + "column", + "rhythm", + "baseline", + "reader", + "story", + "frame", + "quiet", + "layout", + "type", + "paragraph", + "composer", + "weighs", + "every", + "line", + "break", + "texture", + "even", + "hyphen", + "image", + "spread", + "pulse", + "caption", + "colour", + "swatch", + "ink", + "tint", + "gradient", + "style", + "voice", + "printer", + "proof", + "bleed", + "a", + "of", + "and", + "to", + "in", + "with", + "for", + "is", + "on", + "that", + "by", + "it", + "as", + "careful", + "measure", + "leading", + "kerning", + "tracking", + "glyph", + "serif", + "italic", + "heading", + "running", + "folio", +]; + +/// `chars` characters of original filler text in paragraphs of ~400 characters. +fn filler(r: &mut Rng, chars: usize) -> String { + let mut s = String::with_capacity(chars + 16); + let mut para = 0; + while s.len() < chars { + let w = r.pick(WORDS); + if para == 0 { + let mut c = w.chars(); + if let Some(f) = c.next() { + s.extend(f.to_uppercase()); + s.push_str(c.as_str()); + } + } else { + s.push_str(w); + } + para += w.len() + 1; + if para > 400 { + s.push_str(".\n"); + para = 0; + } else { + s.push(' '); + } + } + s +} + +/// Procedural RGB image, PNG-encoded. +fn art_png(w: u32, h: u32, seed: f32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); + } + } + designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() +} + +/// Parameters of the synthetic stress document. +pub struct Spec { + pub pages: usize, + pub frames_per_page: usize, + pub frames_per_story: usize, + pub chars: usize, + pub images: usize, + pub assets: usize, +} + +impl Default for Spec { + fn default() -> Self { + Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } + } +} + +/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text +/// frames, and a strip of placed images (one with a drop shadow per spread). +pub fn synthetic(spec: &Spec) -> Result { + let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); + let lid = d.default_layer(); + d.swatches.push(Swatch { + name: "Perf Gradient".into(), + value: SwatchValue::Gradient { + gradient: Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))], + }, + }, + locked: false, + named: true, + hidden: false, + }); + let mut rng = Rng(0x5eed); + let (aw, ah) = (800u32, 600u32); + let assets: Vec = (0..spec.assets.max(1)) + .map(|k| { + let id = AssetId(d.alloc()); + let data = art_png(aw, ah, k as f32 * 0.37); + d.assets.insert( + id, + Arc::new(Asset { + id, + name: format!("art-{k}.png"), + mime: "image/png".into(), + data: Arc::new(data), + pixels: Some((aw, ah)), + ..Default::default() + }), + ); + id + }) + .collect(); + let total_frames = spec.pages * spec.frames_per_page; + let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); + let per_story = spec.chars / stories.max(1); + let rows = spec.frames_per_page.div_ceil(2); + let (top, strip) = (48.0, 130.0); + let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; + let mut prev: Option = None; + let mut n_frame = 0usize; + let mut placed_images = 0usize; + for abs in 0..spec.pages { + let Some((si, pi)) = d.page_loc(abs) else { break }; + let sr = SpreadRef::Doc(si); + let x0 = d.spreads[si].pages[pi].x; + // Gradient band. + let id = ItemId(d.alloc()); + let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); + band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; + band.fill.gradient_angle = Some(rng.next() * 90.0); + d.insert_item(sr, band, None).map_err(|e| e.to_string())?; + // Text frames. + for k in 0..spec.frames_per_page { + let (c, r) = (k % 2, k / 2); + let fx = x0 + 36.0 + c as f64 * 280.0; + let fy = top + r as f64 * row_h; + let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); + let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); + let text = if first { filler(&mut rng, per_story) } else { String::new() }; + let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?; + if let (false, Some(p)) = (first, prev) { + d.thread(p, fid).map_err(|e| e.to_string())?; + } + prev = Some(fid); + n_frame += 1; + } + // Images: spread the requested count evenly over the pages. + let want = (spec.images * (abs + 1)) / spec.pages - placed_images; + for k in 0..want { + let w = 540.0 / want as f64; + let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); + let asset = assets[(placed_images + k) % assets.len()]; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + let (nw, nh) = (aw as f64, ah as f64); + let s = (r.width() / nw).max(r.height() / nh); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset, + size: (nw, nh), + xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), + clip: None, + auto_fit: designcraft_doc::Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + if k == 0 && pi == 0 { + it.effects.drop_shadow.on = true; + } + d.insert_item(sr, it, None).map_err(|e| e.to_string())?; + } + placed_images += want; + } + Ok(d) +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 87beb0488..164e4c8f4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> { /// The document's inks with its Ink Manager settings. pub(crate) inks: craft_prepress::InkSet, pub cache: &'a Cache, + /// Each story's composition per page it is shown on, for this export: the document doesn't + /// change while it runs, and asking the cache checks every frame of the story again. + composed: HashMap<(designcraft_doc::StoryId, Option), Arc>, pub opts: &'a PdfOptions, /// Current sheet's visible area (spread space) for culling. pub clip: Rect, diff --git a/crates/pdf/src/export/booklet.rs b/crates/pdf/src/export/booklet.rs index fa1aa0655..bc2138623 100644 --- a/crates/pdf/src/export/booklet.rs +++ b/crates/pdf/src/export/booklet.rs @@ -98,6 +98,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts: &pdf_opts, clip: Rect::ZERO, warnings, diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 00352a6f1..a5b7eda8c 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -198,6 +198,7 @@ mod image_content_cache_tests { inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts, clip: Rect::ZERO, warnings: Vec::new(), diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index 18c88dbfd..ca9d1a988 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -172,6 +172,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts, clip: Rect::ZERO, warnings, diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index 08bc05c93..686fbdb24 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -4,6 +4,12 @@ use super::pipeline::{item_exported, layer_exported}; use super::*; impl Exporter<'_> { + /// Story `sid` composed for `page` ([`Cache::get`]), asked of the cache once per export. + fn composed(&mut self, sid: designcraft_doc::StoryId, page: Option) -> Arc { + let (doc, cache) = (self.doc, self.cache); + self.composed.entry((sid, page)).or_insert_with(|| cache.get(doc, sid, page)).clone() + } + /// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases /// first, then the parent. fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) { @@ -325,7 +331,7 @@ impl Exporter<'_> { } } Content::Text(tfr) => { - let cs = self.cache.get(doc, tfr.story, page); + let cs = self.composed(tfr.story, page); if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { @@ -381,7 +387,7 @@ impl Exporter<'_> { s.pop(); } if let Content::Text(tfr) = &it.content { - let cs = self.cache.get(doc, tfr.story, page); + let cs = self.composed(tfr.story, page); if let Some(ft) = cs.frame(it.id) { self.frame_objects(s, ft, xf * doc.text_local(it), page); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b06fad47d..a5c19edc5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -890,3 +890,30 @@ fn a_swatch_colours_a_greyscale_image() { let (s, _) = export(&rgb, Some("PANTONE Test")); assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours"); } + +/// The export asks for each story's composition once per page it is shown on: a parent page's +/// page number still reads its own page everywhere, and a story threaded across pages shows each +/// frame's own lines. +#[test] +fn each_page_shows_its_own_composition() { + use designcraft_doc::story::PAGE_NUMBER; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..NewDocument::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, 300.0, 740.0), lid, &format!("Folio{PAGE_NUMBER}"), ParaFormat::default()).unwrap(); + let (first, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 56.0), lid, "Alpha\nBravo\nCharlie", ParaFormat::default()).unwrap(); + let mut prev = first; + for si in 1..3 { + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 36.0, 300.0, 56.0))); + d.insert_item(SpreadRef::Doc(si), it, None).unwrap(); + d.thread(prev, id).unwrap(); + prev = id; + } + let cache = Cache::new(); + let opts = PdfOptions { created: Some(0), ..PdfOptions::default() }; + let bytes = export_pdf(&d, &cache, &opts).unwrap(); + // Parent items first, then the page's own. + assert_eq!(extract_text(&bytes), ["Folio1Alpha", "Folio2Bravo", "Folio3Charlie"]); + assert_eq!(export_pdf(&d, &cache, &opts).unwrap(), bytes, "a warm cache exports the same bytes"); +} From 0b31b002eb46dc23400e5cbbee4aee2971f91404 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:08:34 +0000 Subject: [PATCH 14/16] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc64..cc9416e9c 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 6ba87b1b0c99592b82546774c202148a82cba1dc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:57 +0000 Subject: [PATCH 15/16] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ae4586eb9..211ed988d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: designcraft app-dir: DesignCraft From eb25f8ad9664fa44fe5f0c41ec7abad49fec7234 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 21:13:27 +0000 Subject: [PATCH 16/16] test(installer): cover the compiled folder searches, resolver chains and wizard folder replay The policy check now reads the compiled AppSearch and RegLocator rows for the remembered-folder properties (registry root, key, value name and type for both scopes) and runs the DIR, FOLDER, PATH and legacy exe actions in sequence order against seeded values, so their precedence and the preset-folder guard cannot regress. It also replays the wizard folder actions in sequence order (a remembered folder beats the new-install default, a typed folder beats both) and requires the machine restore action between the scope page and costing. --- packaging/windows/check-install-policy.ps1 | 76 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index cc9416e9c..5463c0083 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -143,7 +143,8 @@ try { $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and - $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. @@ -174,6 +175,28 @@ try { $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } + # Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered + # folder must win over the new-install default, and a folder the user typed must survive both. + $uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | + Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] }) + $userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' + foreach ($case in @( + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'), + @('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'), + @('1', '', '', '', $machineFolder), + @('', '', '', '', $userDefault), + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) { + Set-SessionProperty $session 'Installed' '' + Set-SessionProperty $session 'ALLUSERS' $case[0] + Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1] + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2] + Set-SessionProperty $session 'INSTALLFOLDER' $case[3] + foreach ($row in $uiFolderActions) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -186,6 +209,57 @@ try { throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' } } + # AppSearch itself never runs in this safe session, so check the compiled searches and then run the + # property actions that turn the search results into the remembered folder, once per scope. + $appSearch = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] } + $locators = @{} + foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row } + $appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$' + # Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off). + $searchContract = @( + @('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'), + @('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'), + @('PATH', 'regex', $appPaths, 'Path', '2'), + @('EXEFOLDER', 'regex', $appPaths, '', '1'), + @('EXE', 'regex', $appPaths, '', '1')) + $resolverCases = @( + @{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' }, + @{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' }, + @{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' }, + @{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' }, + @{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' }, + @{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' }) + foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) { + $letter = $scopeSpec[0]; $target = $scopeSpec[2] + foreach ($item in $searchContract) { + $property = "CRAFT_${letter}_$($item[0])" + $locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null } + $keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] }) + if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) { + throw "Compiled search for $property is missing or points at the wrong registry location." + } + } + $chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 | + Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] }) + $order = @($chain | ForEach-Object { $_[0] }) -join ',' + if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") { + throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order" + } + foreach ($case in $resolverCases) { + Set-SessionProperty $session $target $case.existing + Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir + Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder + Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path + Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder + Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe + foreach ($row in $chain) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $got = Get-SessionProperty $session $target + if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." } + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run