diff --git a/ROADMAP.md b/ROADMAP.md index aec4e71d..989572b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -165,6 +165,7 @@ Newest first. - **2026-10-14** · The Type menu follows InDesign's order (Size, Character ⌘T, Paragraph ⌥⌘T, Glyphs, the style panels, Notes, footnotes and endnotes, then the insert submenus and Show Hidden Characters); Current Page Number has ⌥⇧⌘N. No readiness percentage change. - **2026-10-14** · Colour parameters: a bare `{c,m,y,k}` is always read as 0..100, so C 1 is a 1 % tint and no longer solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change. +- **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index be36947c..054a2ca4 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -16,6 +16,9 @@ const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str> /// Tools-panel groups in order (the first tool of each group shows by default). A `None` group /// marks a separator. +/// +/// In two columns InDesign lays some groups out tool by tool (Content Collector and Content +/// Placer side by side): see [`SPLIT_IN_TWO_COLUMNS`]. pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("selection", "Selection Tool", Some("V"), "tool-selection")], &[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")], @@ -62,10 +65,8 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("scale", "Scale Tool", Some("S"), "tool-scale"), t("shear", "Shear Tool", Some("O"), "tool-shear"), ], - &[ - t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), - t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather"), - ], + &[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient")], + &[t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")], &[], &[t("note", "Note Tool", None, "tool-note")], &[ @@ -77,6 +78,10 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")], ]; +/// Groups (by their first tool) whose tools get a button each when the Tools panel has two +/// columns, as InDesign's does. +pub const SPLIT_IN_TWO_COLUMNS: &[&str] = &["contentCollector"]; + pub fn tool_info(id: &str) -> Option { TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied() } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c5c68e31..e6f8280b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -30,7 +30,7 @@ use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; +pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; pub use layout::CanvasLayout; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1efc41cf..68ee6935 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -68,6 +68,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Current Page Number", ["Aktuelle Seitenzahl", "Numéro de page actuelle", "Número de página actual", "現在のページ番号", "当前页码"]), + ("Apply Color (,)", ["Farbe anwenden (,)", "Appliquer la couleur (,)", "Aplicar color (,)", "カラーを適用 (,)", "应用颜色 (,)"]), + ("Apply Gradient (.)", ["Verlauf anwenden (.)", "Appliquer le dégradé (.)", "Aplicar degradado (.)", "グラデーションを適用 (.)", "应用渐变 (.)"]), + ("View Options", ["Ansichtsoptionen", "Options d’affichage", "Opciones de visualización", "表示オプション", "视图选项"]), + ("Frame Edges", ["Rahmenkanten", "Bords du bloc", "Bordes del marco", "フレーム枠", "框架边缘"]), + ("Rulers", ["Lineale", "Règles", "Reglas", "定規", "标尺"]), + ("Hidden Characters", ["Verborgene Zeichen", "Caractères masqués", "Caracteres ocultos", "制御文字", "隐藏字符"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index eae94858..66e70faf 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -32,6 +32,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "لصق"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), + ("Apply Color (,)", "تطبيق اللون (,)"), + ("Apply Gradient (.)", "تطبيق التدرج (.)"), + ("View Options", "خيارات العرض"), + ("Frame Edges", "حواف الإطار"), + ("Hidden Characters", "الأحرف المخفية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a379cb66..440d64fc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Incolla"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), + ("Apply Color (,)", "Applica colore (,)"), + ("Apply Gradient (.)", "Applica sfumatura (.)"), + ("View Options", "Opzioni di visualizzazione"), + ("Frame Edges", "Bordi cornice"), + ("Hidden Characters", "Caratteri nascosti"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f75170b9..9cb7a937 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "コピー"), ("Paste", "ペースト"), ("Bulleted & Numbered Lists", "箇条書き"), + ("Apply Color (,)", "カラーを適用 (,)"), + ("Apply Gradient (.)", "グラデーションを適用 (.)"), + ("View Options", "表示オプション"), + ("Frame Edges", "フレーム枠"), + ("Hidden Characters", "制御文字"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 223a350a..085b2add 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -36,6 +36,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Current Page Number", "Número da página atual"), ("New Note", "Nova nota"), ("Insert Endnote", "Inserir nota de fim"), + ("Apply Color (,)", "Aplicar cor (,)"), + ("Apply Gradient (.)", "Aplicar degradê (.)"), + ("View Options", "Opções de visualização"), + ("Frame Edges", "Bordas do quadro"), + ("Hidden Characters", "Caracteres ocultos"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index bfe177f7..0d372cc7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -33,6 +33,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Вставити"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), + ("Apply Color (,)", "Застосувати колір (,)"), + ("Apply Gradient (.)", "Застосувати градієнт (.)"), + ("View Options", "Параметри перегляду"), + ("Frame Edges", "Краї фреймів"), + ("Hidden Characters", "Приховані символи"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 6182c4a9..998e8c02 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -455,6 +455,19 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(5.0, 10.0), (4.0, 10.0), (4.0, 17.0), (16.0, 17.0), (16.0, 10.0), (15.0, 10.0)]); } "screen-normal" => pen.rect(3.0, 4.0, 17.0, 16.0), + // View Options: a frame's dashed edges with a ruler's ticks above. + "view-options" => { + pen.w = 1.1; + for (x0, x1) in [(4.0, 6.5), (8.5, 11.5), (13.5, 16.0)] { + pen.line(&[(x0, 9.0), (x1, 9.0)]); + pen.line(&[(x0, 17.0), (x1, 17.0)]); + } + pen.line(&[(4.0, 9.0), (4.0, 17.0)]); + pen.line(&[(16.0, 9.0), (16.0, 17.0)]); + for x in [5.0, 8.0, 11.0, 14.0] { + pen.line(&[(x, 4.0), (x, 6.0)]); + } + } "screen-preview" => { pen.rect(3.0, 4.0, 17.0, 16.0); pen.frect(6.0, 7.0, 14.0, 13.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72502388..c56f5b30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -303,6 +303,12 @@ pub struct UiState { pub palette: Option, #[serde(skip)] pub flyout: Option, + /// The colour and gradient swatches applied last: what Apply Color (,) and Apply Gradient (.) + /// apply again. + #[serde(skip)] + pub last_color: Option, + #[serde(skip)] + pub last_gradient: Option, /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the /// Color panel act on it (X toggles). #[serde(skip)] @@ -384,6 +390,8 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + last_color: None, + last_gradient: None, stroke_front: false, } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 86c2088e..4f7d9313 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,6 +112,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), + ("color.applyColor", "Apply Color", Some(","), "{} — the colour applied last ([Black] at first) to the fill or stroke in front of the proxy"), + ( + "color.applyGradient", + "Apply Gradient", + Some("."), + "{} — the gradient applied last (else the document's first, else white to black) to the fill or stroke in front of the proxy", + ), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1236,6 +1243,8 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::toolbar::default_fill_stroke(app), "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyColor" => crate::toolbar::apply_color(app), + "color.applyGradient" => crate::toolbar::apply_gradient(app), "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8dadaec2..67ec9e40 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -75,6 +75,7 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option) -> Result { + remember_swatch(app, swatch); if colors_affect_text(app) { let (k, kt) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; let mut attrs = json!({k: swatch}); @@ -90,6 +91,19 @@ pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Optio app.run(if stroke { "object.stroke" } else { "object.fill" }, p) } +/// Keep `swatch` as the colour or gradient Apply Color / Apply Gradient use next. +pub fn remember_swatch(app: &mut DesignApp, swatch: &str) { + let Some(st) = app.session.active() else { return }; + if swatch == designcraft_color::swatch::NONE { + return; + } + if designcraft_color::swatch::resolve_gradient(&st.doc.swatches, swatch).is_some() { + app.ui.last_gradient = Some(swatch.to_string()); + } else if st.doc.resolve_color(swatch, 1.0).is_some() { + app.ui.last_color = Some(swatch.to_string()); + } +} + /// Fill (or stroke) tint of the first selected item (1 = 100%). pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { let Some(st) = app.session.active() else { return 1.0 }; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6e7d9848..7e6ca1cd 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,7 +1,7 @@ //! The Tools panel (left): single or double column, tool groups with flyouts, fill/stroke proxy, //! screen mode buttons. -use designcraft_tools::TOOL_GROUPS; +use designcraft_tools::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS}; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -45,13 +45,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(2.0); let current = app.session.tool_id(); let mut chosen: Option<&'static str> = None; - let mut row_items = Vec::new(); + // A slot is a group (its current tool shown, the others in its flyout) or, in two + // columns, one tool of a group InDesign lays out tool by tool. + let mut row_items: Vec)>> = Vec::new(); for (gi, g) in TOOL_GROUPS.iter().enumerate() { if g.is_empty() { row_items.push(None); - continue; + } else if cols == 2 && SPLIT_IN_TWO_COLUMNS.contains(&g[0].id) { + row_items.extend((0..g.len()).map(|k| Some((gi, Some(k))))); + } else { + row_items.push(Some((gi, None))); } - row_items.push(Some(gi)); } let mut i = 0; while i < row_items.len() { @@ -70,8 +74,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); ui.spacing_mut().item_spacing.x = 4.0; for _ in 0..cols { - let Some(Some(gi)) = row_items.get(i).copied() else { break }; - let g = TOOL_GROUPS[gi]; + let Some(Some((gi, pinned))) = row_items.get(i).copied() else { break }; + let group = TOOL_GROUPS[gi]; + let g = match pinned { + Some(k) => &group[k..=k], + None => group, + }; let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]); let active = g.iter().any(|tl| tl.id == current); let tip = match shown.shortcut { @@ -173,7 +181,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.flyout = None; } ui.add_space(8.0); - fill_stroke_proxy(app, ui, width); + fill_stroke_proxy(app, ui, width, cols); }); if cols == 1 { let h = scroll.content_size.y; @@ -192,7 +200,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// The area under the tools (InDesign 2026 §3 group 5): mini Default Fill/Stroke and Swap, the /// overlapping Fill/Stroke proxy, Formatting affects container / text, the Apply None well, a /// utility button and the Screen Mode well. -fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { +fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: usize) { let t = Tokens::get(ui.ctx()); let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); @@ -320,42 +328,147 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { } } ui.add_space(5.0); - // Apply None well (flyout: Apply Color / Gradient / None). + // Apply Color / Apply Gradient / Apply None: three buttons in two columns, as InDesign lays + // them out; one well with the kind in front (and a flyout of the three) in one column. + let front_sw = if stroke_front { &stroke_sw } else { &fill_sw }; + let front_kind = match doc.as_ref() { + Some(_) if front_sw == designcraft_color::swatch::NONE => 2, + Some(d) if designcraft_color::swatch::resolve_gradient(&d.swatches, front_sw).is_some() => 1, + _ => 0, + }; + let applies: [(&str, &str); 3] = + [("Apply Color (,)", "color.applyColor"), ("Apply Gradient (.)", "color.applyGradient"), ("Apply None (/)", "color.applyNone")]; + // Apply Color shows the colour it applies. + let last_color = app.ui.last_color.as_deref().and_then(|sw| doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, sw, 1.0).0)); + let paint_kind = |p: &egui::Painter, r: egui::Rect, k: usize| { + let chip = egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)); + match k { + 0 => { + p.rect_filled(chip, 0.0, last_color.unwrap_or(Color32::BLACK)); + } + 1 => crate::widgets::paint_chip(p, chip, None, Some((Color32::WHITE, Color32::BLACK))), + _ => crate::widgets::paint_chip(p, chip, None, None), + } + p.rect_stroke(chip, 0.0, Stroke::new(0.5, t.border), StrokeKind::Outside); + }; let (row, _) = ui.allocate_exact_size(vec2(width, 21.0), Sense::hover()); - let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); - let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply None (/)")); - }); - ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); - crate::widgets::paint_chip(ui.painter(), chip, None, None); - flyout_triangle(ui.painter(), well, t.icon); - if resp.clicked() { - let _ = app.run("color.applyNone", json!({})); - } - egui::Popup::context_menu(&resp).show(|ui| { - for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = crate::panels::apply_swatch(app, stroke_front, sw, None); - ui.close(); + if cols == 2 { + let pitch = 19.0; + for (k, (tip, id)) in applies.into_iter().enumerate() { + let c = egui::pos2(row.min.x + width / 2.0 + (k as f32 - 1.0) * pitch, row.center().y); + let r = egui::Rect::from_center_size(c, vec2(18.0, 18.0)); + let resp = ui.interact(r, ui.id().with(("proxy_apply", k)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + if k == front_kind { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + paint_kind(ui.painter(), r, k); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }) + .clicked() + { + let _ = app.run(id, json!({})); } } - }); + } else { + let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); + let (tip, id) = applies[front_kind]; + let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + let resp = resp.on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + paint_kind(ui.painter(), well, front_kind); + flyout_triangle(ui.painter(), well, t.icon); + if resp.clicked() { + let _ = app.run(id, json!({})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (tip, id) in applies { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, tip))).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); + } ui.add_space(6.0); separator(ui, &t); - // Utility (frame options). + // View Options (InDesign's: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid, Hidden + // Characters, each checked when shown). ui.vertical_centered(|ui| { - if tool_button(ui, "frame-options", false, crate::i18n::tr(&app.ui.language, "Text Frame Options")).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); - } + let resp = tool_button(ui, "view-options", false, crate::i18n::tr(&app.ui.language, "View Options")); + flyout_triangle(ui.painter(), resp.rect.shrink2(vec2(2.0, 2.0)), t.icon); + let open = resp.clicked(); + let popup = egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)); + popup.show(|ui| { + for (k, id) in + ["view.frameEdges", "view.rulers", "-", "view.guides", "view.smartGuides", "view.baselineGrid", "-", "view.hiddenCharacters"] + .into_iter() + .enumerate() + { + if id == "-" { + ui.separator(); + continue; + } + let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false); + let label = VIEW_OPTIONS[k]; + let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or(""); + let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label)); + if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); }); separator(ui, &t); - // Screen Mode well with flyout. + // Screen Mode: Normal and Preview (with Bleed, Slug and Presentation) side by side in two + // columns, as InDesign's; one well with the five in its flyout in one column. let (row, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let mode = app.ui.screen_mode; + let modes = [("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")]; + if cols == 2 { + let normal = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 - 15.0, row.center().y), vec2(28.0, 21.0)); + let other = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 + 15.0, row.center().y), vec2(28.0, 21.0)); + let is_normal = mode == crate::ScreenMode::Normal; + for (r, on, icon) in [(normal, is_normal, "screen-normal"), (other, !is_normal, "screen-preview")] { + if on { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(15.0, 15.0)), icon, if on { t.text_strong } else { t.icon }); + } + flyout_triangle(ui.painter(), other, t.icon); + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let (normal_tip, other_tip) = (tr("Normal"), format!("{} (W)", tr("Preview"))); + let resp = ui.interact(normal, ui.id().with("screen_normal"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &normal_tip)); + if resp.on_hover_text(&normal_tip).clicked() { + let _ = app.run("view.screenMode", json!({"mode": "normal"})); + } + let resp = ui.interact(other, ui.id().with("screen_mode"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &other_tip)); + let resp = resp.on_hover_text(&other_tip); + if resp.clicked() { + let _ = app.run("view.screenMode", json!({"mode": "preview"})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (label, m) in modes { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + let _ = app.run("view.screenMode", json!({"mode": m})); + ui.close(); + } + } + }); + return; + } let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click()); ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let preview = app.ui.screen_mode == crate::ScreenMode::Preview; + let preview = mode == crate::ScreenMode::Preview; icons::paint( ui.painter(), egui::Rect::from_center_size(well.center(), vec2(15.0, 15.0)), @@ -370,7 +483,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let _ = app.run("view.togglePreview", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { - for (label, mode) in [("Normal", "normal"), ("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")] { + for (label, mode) in std::iter::once(("Normal", "normal")).chain(modes) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { let _ = app.run("view.screenMode", json!({"mode": mode})); ui.close(); @@ -379,6 +492,9 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }); } +/// The View Options flyout's labels, by position (separators keep theirs empty). +const VIEW_OPTIONS: [&str; 8] = ["Frame Edges", "Rulers", "", "Guides", "Smart Guides", "Baseline Grid", "", "Hidden Characters"]; + /// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move). fn transform_dialog_of(tool: &str) -> Option<&'static str> { match tool { @@ -444,6 +560,44 @@ pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { Ok(json!({"strokeFront": app.ui.stroke_front})) } +/// Apply Color (,): the colour applied last ([Black] at first) to the fill or stroke in front. +pub(crate) fn apply_color(app: &mut DesignApp) -> Result { + let sw = app.ui.last_color.clone().filter(|s| app.session.active().is_some_and(|d| d.doc.swatch(s).is_some())); + crate::panels::apply_swatch(app, app.ui.stroke_front, sw.as_deref().unwrap_or("[Black]"), None) +} + +/// Apply Gradient (.): the gradient applied last, else the document's first gradient swatch, else +/// a new white-to-black gradient (InDesign's default), on the fill or stroke in front. +pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result { + let st = app.session.active().ok_or("no document")?; + let known = app + .ui + .last_gradient + .clone() + .filter(|s| designcraft_color::swatch::resolve_gradient(&st.doc.swatches, s).is_some()) + .or_else(|| st.doc.swatches.iter().find(|w| matches!(w.value, designcraft_color::SwatchValue::Gradient { .. })).map(|w| w.name.clone())); + let stroke = app.ui.stroke_front; + if let Some(sw) = known { + return crate::panels::apply_swatch(app, stroke, &sw, None); + } + if crate::panels::colors_affect_text(app) { + return Err("no gradient to apply yet: make one in the Gradient panel".into()); + } + let fill = crate::panels::color_target(app).map(|t| t.0); + let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]); + let r = app.run("object.gradient", json!({"stops": stops}))?; + let sw = r["swatch"].as_str().unwrap_or_default().to_string(); + app.ui.last_gradient = Some(sw.clone()); + if stroke { + // The gradient was made on the fill: it goes to the stroke, the fill gets its swatch back. + crate::panels::apply_swatch(app, true, &sw, None)?; + if let Some(f) = fill { + app.run("object.fill", json!({"swatch": f}))?; + } + } + Ok(r) +} + /// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. pub(crate) fn apply_none(app: &mut DesignApp) -> Result { crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) @@ -550,6 +704,69 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + #[test] + fn the_tools_panel_lays_out_like_indesigns() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020): Gradient Swatch and Gradient + // Feather are tools of their own; in two columns Content Collector and Placer, Apply + // Color / Gradient / None and Normal / Preview each get a button. + for double in [false, true] { + let h = test_window::open(app(double), vec2(1400.0, 900.0)); + let _ = rect(&h, "Gradient Swatch Tool (G)"); + let _ = rect(&h, "Gradient Feather Tool (Shift+G)"); + let _ = rect(&h, "View Options"); + let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; + for label in split { + assert_eq!(h.query_by_label(label).is_some(), double, "double column {double}: {label}"); + } + let _ = rect(&h, "Content Collector Tool (B)"); + // One column: the Apply well names what is in front (no fill: Apply None). + let _ = rect(&h, "Apply None (/)"); + if double { + let (a, b) = (rect(&h, "Content Collector Tool (B)"), rect(&h, "Content Placer Tool (B)")); + assert!((a.center().y - b.center().y).abs() < 1.0 && a.center().x < b.center().x, "side by side: {a:?} {b:?}"); + let (n, p) = (rect(&h, "Normal"), rect(&h, "Preview (W)")); + assert!((n.center().y - p.center().y).abs() < 1.0, "Normal and Preview side by side"); + } + } + } + + #[test] + fn apply_color_and_gradient_reuse_the_last_ones() { + use serde_json::json; + let mut app = app(true); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let fill = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.stroke.swatch.clone()) + }; + // Apply Color: [Black] at first, then the colour last applied. + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[Black]"); + crate::panels::apply_swatch(&mut app, false, "C=100 M=0 Y=0 K=0", None).unwrap(); + app.run("color.applyNone", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[None]"); + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "C=100 M=0 Y=0 K=0"); + // Apply Gradient: white to black when there's none yet, then that one again. + app.run("color.applyGradient", json!({})).unwrap(); + let g = fill(&app).0; + let doc = &app.session.active().unwrap().doc; + let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &g).expect("a gradient"); + assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3])); + app.run("color.applyColor", json!({})).unwrap(); + // With the stroke in front, it goes to the stroke; the fill keeps its colour. + app.ui.stroke_front = true; + app.run("color.applyGradient", json!({})).unwrap(); + assert_eq!(fill(&app), ("C=100 M=0 Y=0 K=0".to_string(), g)); + // The shortcuts: , and . + for (id, sc) in [("color.applyColor", ","), ("color.applyGradient", ".")] { + assert_eq!(crate::menus::ui_label(id).and_then(|c| c.1), Some(sc)); + } + } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. #[test] fn fill_stroke_proxy_keys() {