Part of #812: more tools open their options on a double-click

Double-clicking a tool's button (or tool.options {tool}) now also opens:
- Artboard tool: Artboard Options of the active artboard (the Artboard
  tool's while it is in use, else the window's);
- the graph tools: Graph Type for the selected graph (an error without
  one), the dialog Object > Graph > Type opens;
- the Symbolism tools: Symbolism Tools Options with the brush the eight
  tools share (Diameter, Intensity, Symbol Set Density);
- Magic Wand: the Magic Wand panel.

The Object > Graph dialogs' opener is shared with the toolbar. The new
labels are translated in the eight complete catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-10 13:00:21 +07:00
co-authored by Claude Opus 5.5
parent ae27d899b8
commit 990d869897
17 changed files with 188 additions and 18 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -16,11 +16,16 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"flare" => Box::new(FlareTool::default()),
"reshape" => Box::new(ReshapeTool::default()),
"shaper" => Box::new(ShaperTool::default()),
id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)),
id if is_graph_tool(id) => Box::new(GraphTool::new(id)),
_ => return None,
})
}
/// Whether `id` is one of the graph tools (`columnGraph`, `pieGraph`…).
pub fn is_graph_tool(id: &str) -> bool {
id.ends_with("Graph") && vectorcraft_doc::GraphKind::parse(id).is_some()
}
/// The Flare Tool Options as `shape.flare` takes them: (key, default, least, most). The diameter
/// and the rings' path length are in points, the counts whole, the direction in degrees, the
/// rest percentages.
+2 -1
View File
@@ -14,7 +14,8 @@ type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static st
/// The Liquify tools.
pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"];
const SYMBOLISM: &[&str] =
/// The Symbolism tools.
pub const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
+21 -2
View File
@@ -1,5 +1,5 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
//! Artboard Options (double-click with the Artboard tool, or double-click the tool): the
//! artboard's fields, sizes parsed with units.
use serde_json::{Value, json};
@@ -9,6 +9,25 @@ use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
/// The dialog kind of Artboard Options.
pub const KIND: &str = "artboardOptions";
/// Open Artboard Options for the active artboard: the Artboard tool's while it is in use, else the
/// window's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let index = if app.session.tool_id() == "artboard" {
app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
} else {
app.view().map_or(0, |v| v.artboard)
};
let st = app.session.active().ok_or("no document")?;
let a = st.doc.artboards.get(index).ok_or("no artboard")?;
let r = a.rect;
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(json!({ "dialog": KIND }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
+4 -2
View File
@@ -8,7 +8,7 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod artboard_options;
pub mod blend_options;
pub mod brush_options;
pub mod color_balance;
@@ -79,6 +79,7 @@ pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_conflict;
pub mod swatch_options;
pub mod symbolism_options;
mod text_export;
pub mod text_import;
mod tiff_bmp_tga;
@@ -205,7 +206,7 @@ registry! {
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
@@ -278,6 +279,7 @@ registry! {
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
FreehandOptions: [freehand::KIND] => freehand::SPEC,
FlareOptions: [flare_options::KIND] => flare_options::SPEC,
SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
@@ -0,0 +1,81 @@
//! Symbolism Tools Options (double-click a Symbolism tool): the brush the eight tools share, which
//! lasts across tool switches and is saved with the preferences. OK sets it with
//! `tool.setOption {tool, values}`.
//!
//! Fields: `tool`; `diameter` (points), `intensity` and `density` (Symbol Set Density), 1–10.
use serde_json::{Map, Value, json};
use super::{DialogResult, DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Symbolism Tools Options.
pub const KIND: &str = "symbolismOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Symbolism Tools Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// The options the dialog edits.
const KEYS: [&str; 3] = ["diameter", "intensity", "density"];
/// Width of the label column.
const LABEL_W: f32 = 130.0;
/// Open the options of Symbolism tool `tool` (one of `vectorcraft_tools::settings::SYMBOLISM`)
/// with their current values.
pub fn open(app: &mut VectorcraftApp, tool: &str) -> Value {
let o = app.session.tool_options_of(tool);
let mut fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
fields.insert("tool".into(), json!(tool));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
json!({ "dialog": KIND })
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
widgets::label_row(ui, tl!("Diameter:"), LABEL_W, |ui| {
form::length(ui, d, "diameter", unit, 100.0);
});
for (key, label) in [("intensity", tl!("Intensity:")), ("density", tl!("Symbol Set Density:"))] {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, ("symbolism", key), d.f64(key, 5.0), 1.0..=10.0, "", 0, 100.0) {
d.fields.insert(key.into(), json!(v));
}
});
}
false
}
/// OK: set the brush the Symbolism tools share (the tool keeps it within its ranges).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let values: Map<String, Value> = KEYS.iter().filter(|k| d.fields.contains_key(**k)).map(|k| (k.to_string(), json!(d.f64(k, 0.0)))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": d.str("tool"), "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn the_symbolism_tools_share_the_options_ok_sets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("tool.options", json!({"tool": "symbolSizer"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in ["Symbolism Tools Options", "Diameter:", "Intensity:", "Symbol Set Density:"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.str("tool"), d.f64("diameter", 0.0), d.f64("intensity", 0.0)), ("symbolSizer".to_string(), 80.0, 5.0));
for (k, v) in [("diameter", json!(120)), ("intensity", json!(9)), ("density", json!(2))] {
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
// The Sprayer has them too: one brush for the eight tools.
let o = app.session.tool_options_of("symbolSprayer");
assert_eq!((o["diameter"].as_f64(), o["intensity"].as_f64(), o["density"].as_f64()), (Some(120.0), Some(9.0), Some(2.0)));
}
}
+2
View File
@@ -1002,6 +1002,8 @@
Path: Trazado:
Largest: Más grande:
Direction: Dirección:
Symbolism Tools Options Opciones de las herramientas de símbolos
Symbol Set Density: Densidad del conjunto de símbolos:
Flat Image Imagen acoplada
Flat Logo Logotipo plano
Flatness: Lisura:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1001,6 +1001,8 @@
Path: Tracé :
Largest: Le plus grand :
Direction: Direction :
Symbolism Tools Options Options des outils de symbole
Symbol Set Density: Densité du jeu de symboles :
Flat Image Image aplatie
Flat Logo Logo en aplats
Flatness: Planéité :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1001,6 +1001,8 @@
Path: Tracciato:
Largest: Più grande:
Direction: Direzione:
Symbolism Tools Options Opzioni strumenti simboli
Symbol Set Density: Densità del set di simboli:
Flat Image Immagine appiattita
Flat Logo Logo piatto
Flatness: Planarità:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2871,6 +2871,8 @@
Path: パス:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options シンボルツールオプション
Symbol Set Density: シンボルセットの密度:
Flattener presets 分割・統合プリセット
Focal Fill 焦点の塗り
Focal Stroke 焦点の線
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -999,6 +999,8 @@
Path: Контур:
Largest: Самое большое:
Direction: Направление:
Symbolism Tools Options Параметры инструментов символов
Symbol Set Density: Плотность набора символов:
Flat Image Плоское изображение
Flat Logo Плоский логотип
Flatness: Плоскостность:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -995,6 +995,8 @@
Path: Контур:
Largest: Найбільше:
Direction: Напрямок:
Symbolism Tools Options Параметри інструментів символів
Symbol Set Density: Щільність набору символів:
Flat Image Плоске зображення
Flat Logo Плоский логотип
Flatness: Площинність:
Can't render this file because it contains an unexpected character in line 634 and column 40.
+2
View File
@@ -979,6 +979,8 @@
Path: 路径:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符号工具选项
Symbol Set Density: 符号组密度:
Flat Image 平面图像
Flatness: 平滑度:
Flatten Transparency 拼合透明度
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1002,6 +1002,8 @@
Path: 路徑:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符號工具選項
Symbol Set Density: 符號組密度:
Flat Image 平面影像
Flatness: 平滑度:
Flatten Artwork 將圖稿平面化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11 -7
View File
@@ -353,7 +353,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient → the Gradient panel (window.panel), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
),
(
"ui.colorGuideOptions",
@@ -2964,6 +2964,14 @@ fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values
/// (the command's query).
pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" }))
}
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
@@ -3007,12 +3015,8 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(v) => {
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return;
}
+40 -2
View File
@@ -312,8 +312,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::menus::invoke(app, &id, json!({}));
Ok(json!({ "dialog": dialog }))
}
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
// The Gradient and Magic Wand tools: their panels.
"gradient" | "magicWand" if app.ui.open_panel.as_deref() == Some(tool) => Ok(json!({ "open": tool })),
"gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })),
"artboard" => crate::dialogs::artboard_options::open(app),
"eyedropper" => {
crate::dialogs::eyedropper::open(app);
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
@@ -334,6 +336,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
_ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool),
// The freehand tools: their Tool Options (Fidelity, fill, the tolerances, the brush size).
_ if crate::dialogs::freehand::TOOLS.contains(&tool) => crate::dialogs::freehand::open(app, tool),
// The Symbolism tools: the brush they share.
_ if vectorcraft_tools::settings::SYMBOLISM.contains(&tool) => Ok(crate::dialogs::symbolism_options::open(app, tool)),
// The graph tools: Graph Type for the selected graph.
_ if vectorcraft_tools::extra::is_graph_tool(tool) => crate::menus::graph_dialog(app, "graph.setType"),
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
@@ -817,6 +823,38 @@ pub(crate) mod tests {
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a
/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel.
#[test]
fn more_tools_open_their_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
// Without the Artboard tool, the window's artboard.
assert_eq!(open_options(&mut app, "artboard").unwrap()["dialog"], "artboardOptions");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("width", 0.0), d.str("name")), (0.0, 300.0, "Artboard 1".to_string()));
// With it, its active artboard.
app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 50})).unwrap();
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap();
open_options(&mut app, "artboard").unwrap();
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("x", 0.0), d.f64("width", 0.0)), (1.0, 400.0, 100.0));
// Graph Type needs a selected graph.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(open_options(&mut app, "pieGraph").is_err());
assert!(app.ui.dialog.is_none());
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
assert_eq!(open_options(&mut app, "pieGraph").unwrap()["dialog"], "command");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.str("__command"), d.str("type")), ("graph.setType".to_string(), "column".to_string()));
// The Magic Wand panel opens, and stays open on a second double-click.
open_options(&mut app, "magicWand").unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
assert_eq!(open_options(&mut app, "magicWand").unwrap()["open"], "magicWand");
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
+5 -1
View File
@@ -2697,7 +2697,11 @@ desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`)
`opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and `largest` (%), `rays`, `rings`,
`direction` (°), and `raysOn` and `ringsOn` (the Rays and Rings checkboxes; off, the flare has none). OK keeps them
(`tool.setOption {tool: "flare", values}`) and the next flare dragged out uses them; a click with the tool opens the
same dialog, and its OK also draws a flare there.
same dialog, and its OK also draws a flare there. The Artboard tool's are the active artboard's Artboard Options
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's), a graph tool's
the selected graph's Graph Type (an error without one), a Symbolism tool's the Symbolism Tools Options (dialog
`symbolismOptions`: `diameter` in points, `intensity` and `density` 1–10, one brush for the eight tools) and the
Magic Wand tool's its panel.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"flare","values":{"rays":24,"ringsOn":false}}}}