From cbba1fe7e1cc1e148e0e862090938f704b86e0ab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 18:15:14 +0200 Subject: [PATCH] Tools panel as InDesign's: Gradient Feather a tool of its own; two-column layout; View Options; Apply Color and Apply Gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020) walks the Tools panel in one and two columns. Compared with it: - Gradient Swatch and Gradient Feather are separate tools (one flyout here before). - In two columns InDesign lays out Content Collector and Content Placer, Apply Color / Apply Gradient / Apply None and Normal / Preview (with Bleed, Slug, Presentation in its flyout) side by side (SPLIT_IN_TWO_COLUMNS). One column keeps one well with a flyout, which now names what is in front (Apply Color, Apply Gradient or Apply None). - The utility button is View Options: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters, checked when shown, with their shortcuts (17:40). It was Text Frame Options (Object › Text Frame Options…, ⌘B, still opens it). - Apply Color (,) applies the colour applied last ([Black] at first) and Apply Gradient (.) the gradient applied last, else the document's first, else white to black (InDesign's default), to the fill or stroke in front. The flyout had Apply Color as [Black] and no Apply Gradient. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/catalog.rs | 13 +- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/i18n.rs | 6 + crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/icons.rs | 13 ++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/menus.rs | 9 + crates/ui-egui/src/panels/mod.rs | 14 ++ crates/ui-egui/src/toolbar.rs | 283 +++++++++++++++++++++++++++---- 14 files changed, 336 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a0410..0c39b7ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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 8dbc262b..5e1e199c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -29,7 +29,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 6d6503a0..73f8844c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("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 76951196..dc5648ad 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("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 747511ae..c01935da 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("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 263d4226..9a07eee0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("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 3f955f68..c5a57bef 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("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 efccfc16..369d30bf 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("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 c9346437..f9e96526 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -454,6 +454,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 1db2f591..f5ccb7d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,6 +294,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)] @@ -374,6 +380,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 dc8b88bf..ad37fad6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -106,6 +106,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+;"), "{}"), @@ -1151,6 +1158,8 @@ pub fn run_ui(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 baf9a125..fbfca247 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,6 +69,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}); @@ -84,6 +85,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() {