Files
vectorcraft/crates/ui-egui/src/dialogs/freehand.rs
T
bflatasticandClaude Opus 5.5 505f3bd7ae Freehand Tool Options: Accurate/Smooth ends on the Fidelity slider, Spanish rows (review of #390)
The Fidelity slider names its ends, Accurate and Smooth, through a
shared form::slider_ends that the Color Guide Options' Less/More now use
too. The dialog's strings get Spanish rows (complete catalog), and
Accurate rows in every catalog. ROADMAP: the Drawing tools row lists
what the freehand Tool Options still lack (Keep selected, Alt to the
Smooth tool, Blob Brush Merge Only with Selection, brush angle and
roundness).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:32:09 +07:00

218 lines
9.8 KiB
Rust

//! Tool Options of the freehand tools (double-click Pencil, Paintbrush, Smooth, Blob Brush or
//! Eraser): the options each tool keeps, which last across tool switches and are saved with the
//! preferences. OK sets them with `tool.setOption {tool, values}`.
//!
//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush and Smooth), `fill` (Pencil and
//! Paintbrush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and
//! Paintbrush) and `size` (points; Blob Brush and Eraser).
use serde_json::{Map, Value, json};
use super::document_setup::check;
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the freehand tools' Tool Options.
pub const KIND: &str = "freehandOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 420.0, ..DialogSpec::FORM };
/// The tools the dialog serves.
pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"];
/// The options the dialog edits, in the order it shows them (a tool has those it reports).
const KEYS: [&str; 5] = ["fidelity", "size", "fill", "closeWithin", "editWithin"];
/// Width of the label column: wide enough for the tolerances' labels when there are any.
fn label_w(d: &Dialog) -> f32 {
if d.fields.contains_key("closeWithin") { 230.0 } else { 90.0 }
}
/// The range of the Fidelity slider (points); the field takes what the tool allows.
const FIDELITY: (f64, f64) = (0.5, 20.0);
fn heading(d: &Dialog) -> String {
let label = vectorcraft_tools::tool_info(&d.str("tool")).map_or("", |t| t.label);
crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))])
}
/// Open the options of freehand tool `tool` with their current values.
pub fn open(app: &mut VectorcraftApp, tool: &str) -> Result<Value, String> {
if !TOOLS.contains(&tool) {
return Err(format!("`{tool}` isn't a freehand tool"));
}
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 });
Ok(json!({ "dialog": KIND }))
}
/// A row with a number field for `d.fields[key]` (`suffix` is its unit), kept within `range`.
fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: &str, decimals: usize, range: (f64, f64)) {
let label_w = label_w(d);
let v = d.f64(key, 0.0);
widgets::label_row(ui, label, label_w, |ui| {
if let Some(x) = widgets::plain_field(ui, ("freehand-field", key), v, suffix, decimals, 72.0) {
d.fields.insert(key.into(), json!(x.clamp(range.0, range.1)));
}
});
}
/// The Fidelity row: a slider over [`FIDELITY`] in tenths, and a field.
fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) {
let label_w = label_w(d);
let t = Tokens::get(ui.ctx());
let v = d.f64("fidelity", 1.5);
let (min, max) = FIDELITY;
let mut new = None;
widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| {
let at = ((v.clamp(min, max) - min) / (max - min)) as f32;
if let (Some(x), _) = widgets::color_slider(ui, "freehand-fidelity", at, form::SLIDER_WIDTH, &|_| t.input_border) {
new = Some(((min + x as f64 * (max - min)) * 10.0).round() / 10.0);
}
if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) {
new = Some(x.clamp(0.05, 100.0));
}
});
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert("fidelity".into(), json!(n));
}
form::slider_ends(ui, label_w, (tl!("Accurate"), tl!("Smooth")));
ui.label(
egui::RichText::new(tl!("A lower value follows the stroke closely; a higher one gives fewer points and smoother curves."))
.size(11.5)
.color(t.text_dim),
);
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if d.fields.contains_key("fidelity") {
fidelity(ui, d);
ui.add_space(10.0);
}
if d.fields.contains_key("size") {
number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0));
}
if d.fields.contains_key("fill") {
check(ui, d, "fill", tl!("Fill new strokes"));
ui.add_space(6.0);
}
if d.fields.contains_key("closeWithin") {
number(ui, d, ("closeWithin", tl!("Close paths when ends are within:")), "px", 0, (0.0, 100.0));
}
if d.fields.contains_key("editWithin") {
number(ui, d, ("editWithin", tl!("Edit selected paths within:")), "px", 0, (0.0, 100.0));
}
false
}
/// OK: set the tool's options.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": d.str("tool"), "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
/// The labels the open dialog paints.
fn painted(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn each_tool_shows_its_options_and_ok_sets_them() {
let mut app = app();
// Pencil: Fidelity, fill, the two tolerances; no Size.
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
let text = painted(&mut app);
for label in [
"Pencil Tool Options",
"Fidelity:",
"Accurate",
"Smooth",
"Fill new strokes",
"Close paths when ends are within:",
"Edit selected paths within:",
] {
assert!(text.contains(label), "{label} in {text}");
}
assert!(!text.contains("Size:"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("fidelity", 0.0), d.bool("fill"), d.f64("closeWithin", 0.0), d.f64("editWithin", 0.0)), (1.5, false, 15.0, 12.0));
for (k, v) in [("fidelity", json!(6.5)), ("fill", json!(true)), ("closeWithin", json!(0)), ("editWithin", json!(20))] {
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let o = app.session.tool_options_of("pencil");
assert_eq!(
(o["fidelity"].as_f64(), o["fill"].as_bool(), o["closeWithin"].as_f64(), o["editWithin"].as_f64()),
(Some(6.5), Some(true), Some(0.0), Some(20.0))
);
// The Paintbrush keeps its own: the Pencil's change left it alone.
app.run("tool.options", json!({"tool": "paintbrush"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("fidelity", 0.0), d.bool("fill")), (1.5, false));
assert!(painted(&mut app).contains("Paintbrush Tool Options"));
// Smooth has Fidelity only, the Blob Brush and the Eraser Size only.
app.run("tool.options", json!({"tool": "smooth"})).unwrap();
let text = painted(&mut app);
assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}");
assert!(!text.contains("Fill new strokes") && !text.contains("Size:"), "{text}");
for tool in ["blobBrush", "eraser"] {
app.run("tool.options", json!({"tool": tool})).unwrap();
let text = painted(&mut app);
assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}");
app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}");
}
}
#[test]
fn a_new_fidelity_changes_what_the_pencil_draws() {
let mut app = app();
// The anchors of a wave drawn around height `y0`, with nothing selected (a stroke starting
// at the end of a selected path would continue it).
let anchors = |app: &mut VectorcraftApp, y0: f64| {
app.run("select.set", json!({"ids": []})).unwrap();
app.select_tool("pencil");
let view = app.view_info();
let pts: Vec<_> = (0..=40).map(|i| (20.0 + i as f64 * 8.0, y0 + 30.0 * (i as f64 * 0.25).sin())).collect();
let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y);
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view);
for p in &pts[1..] {
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view);
}
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view);
let doc = &app.session.doc().unwrap().doc;
doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>())
};
let precise = anchors(&mut app, 100.0);
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(12));
super::super::confirm(&mut app).unwrap();
let smooth = anchors(&mut app, 200.0);
assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default");
}
#[test]
fn the_other_tools_have_no_freehand_options() {
let mut app = app();
assert!(open(&mut app, "pen").is_err());
assert!(app.ui.dialog.is_none());
}
}