# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ar.rs
#	crates/ui-egui/src/i18n/it.rs
#	crates/ui-egui/src/i18n/ja.rs
#	crates/ui-egui/src/i18n/pt_br.rs
#	crates/ui-egui/src/i18n/uk.rs
This commit is contained in:
Brandon Thomas
2026-10-14 22:33:55 -04:00
14 changed files with 336 additions and 38 deletions
+1
View File
@@ -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
+9 -4
View File
@@ -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<ToolInfo> {
TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied()
}
+1 -1
View File
@@ -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)]
+6
View File
@@ -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", "消去", "清除"]),
+5
View File
@@ -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", "مسح"),
+5
View File
@@ -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"),
+5
View File
@@ -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", "消去"),
+5
View File
@@ -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"),
+5
View File
@@ -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", "Очистити"),
+13
View File
@@ -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);
+8
View File
@@ -303,6 +303,12 @@ pub struct UiState {
pub palette: Option<String>,
#[serde(skip)]
pub flyout: Option<usize>,
/// The colour and gradient swatches applied last: what Apply Color (,) and Apply Gradient (.)
/// apply again.
#[serde(skip)]
pub last_color: Option<String>,
#[serde(skip)]
pub last_gradient: Option<String>,
/// 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,
}
}
+9
View File
@@ -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<Result<Value
"color.defaultFillStroke" => 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" => {
+14
View File
@@ -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<f32>) -> Result<Value, String> {
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 };
+250 -33
View File
@@ -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<Option<(usize, Option<usize>)>> = 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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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() {