diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 7589cab65..6ade1874e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -14,7 +14,10 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param}; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or, + str_param, switch_or, +}; pub fn specs() -> Vec { vec![ @@ -153,6 +156,15 @@ pub fn specs() -> Vec { has_selection, simplify ), + cmd!( + "object.path.smooth", + "Smooth…", + ["Object", "Path"], + None, + "{level?: 0..100 (50; the Smooth slider: 0 minimum smoothing, the path staying within 0.1% of its longer side, 100 maximum: refitted with curves within up to 2.5% of it, corners kept only where the path turns back, never with more anchor points), auto?: bool (Auto-Smooth: level 50), ids?: [id…] (default: the selection)} smooth each path under the objects, compound path members included, as one undo step → {ids, before, after} anchor counts, and the level used", + has_selection, + smooth + ), cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points), cmd!( "object.path.divideObjectsBelow", @@ -957,20 +969,20 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec { /// selected segments when they were picked as segments). `None` selects the path whole. type PartSelection = Option<(BTreeSet, Option>)>; -/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its +/// Replace each path under `roots` ([`path_ids`]) by what `f` makes of it, given its /// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how /// many anchors they have before and after). A path that `f` would empty or returns unchanged is /// left as it is, with its live shape and its direct selection; a replaced path loses its live /// shape and has the direct selection `f` gives it. fn edit_paths( s: &mut Session, + roots: &[NodeId], label: &str, f: impl Fn(&PathData, Option<&BTreeSet>, Option<&BTreeSet>) -> (PathData, PartSelection), ) -> Result<(Vec, usize, usize)> { - let roots = selected_roots(s)?; let st = s.doc()?; let (d, sel) = (&st.doc, &st.selection); - let ids = path_ids(d, &roots); + let ids = path_ids(d, roots); if ids.is_empty() { return Err(EngineError::Other(format!("{label}: select paths"))); } @@ -1064,7 +1076,8 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let whole = bool_or(p, "wholePaths", false); // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the // other anchors it had selected) direct-selected. - let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| { + let roots = selected_roots(s)?; + let (ids, before, after) = edit_paths(s, &roots, "Simplify", |path, anchors, segments| { let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else { return (po::simplify_with(path, &opts), None); }; @@ -1084,8 +1097,20 @@ fn simplify(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Object › Path › Smooth (`object.path.smooth`): [`po::smooth_level`] on each path under the +/// objects given or selected, as one undo step; Auto-Smooth (`auto`) uses [`po::SMOOTH_AUTO_LEVEL`]. +fn smooth(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.path.smooth"; + let level = number_or(p, "level", po::SMOOTH_AUTO_LEVEL, C)?.round().clamp(0.0, 100.0); + let level = if switch_or(p, "auto", false, C)? { po::SMOOTH_AUTO_LEVEL } else { level }; + let roots = checked_ids_or_selection(s, p, C)?; + let (ids, before, after) = edit_paths(s, &roots, "Smooth", |path, _, _| (po::smooth_level(path, level), None))?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after, "level": level })) +} + fn add_anchor_points(s: &mut Session, _: &Value) -> Result { - let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; + let roots = selected_roots(s)?; + let (ids, before, after) = edit_paths(s, &roots, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebae936f8..7f32ca994 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,7 +37,7 @@ pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; pub use vectorcraft_doc::cancel; -pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance}; +pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, simplify_level_tolerance}; pub use vectorcraft_tools as tools; /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eb36d7943..7d3064c92 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -140,6 +140,7 @@ mod shortcutset; mod simplify_part; mod slices; mod smartguides; +mod smooth_path; mod store; mod strokegeom; mod strokegradient; diff --git a/crates/engine/src/tests/smooth_path.rs b/crates/engine/src/tests/smooth_path.rs new file mode 100644 index 000000000..cf946cbe4 --- /dev/null +++ b/crates/engine/src/tests/smooth_path.rs @@ -0,0 +1,163 @@ +//! Object › Path › Smooth (`object.path.smooth`): the slider from minimum to maximum smoothing and +//! Auto-Smooth, as the Smooth slider help page describes +//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html). + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::kurbo::ParamCurveNearest; +use vectorcraft_geom::{ParamCurve, PathData, Point}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path_of(s: &Session, id: NodeId) -> PathData { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + _ => panic!("not a path"), + } +} + +/// A path through `points`, selected: its id. +fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +/// A sine wave 300 pt long with an anchor every 3 pt, its anchors 1.5 pt above and below it in turn. +fn jagged_wave(s: &mut Session) -> NodeId { + let pts: Vec<(f64, f64)> = (0..=100u32) + .map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin() + if i.is_multiple_of(2) { 1.5 } else { -1.5 })) + .collect(); + polyline(s, &pts, false) +} + +/// A closed ring of radius `r` around (`cx`, 300) with 120 anchors, `jag` pt in and out in turn. +fn jagged_ring(s: &mut Session, cx: f64, r: f64, jag: f64) -> NodeId { + let pts: Vec<(f64, f64)> = (0..120u32) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 120.0; + let rr = r + if i.is_multiple_of(2) { jag } else { -jag }; + (cx + rr * a.cos(), 300.0 + rr * a.sin()) + }) + .collect(); + polyline(s, &pts, true) +} + +/// The farthest that 11 points on each segment of `from` lie from the nearest point of `to`. +fn farthest(from: &PathData, to: &PathData) -> f64 { + let off = |p: Point| { + to.subpaths + .iter() + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i).nearest(p, 1e-9).distance_sq.sqrt())) + .fold(f64::INFINITY, f64::min) + }; + from.subpaths + .iter() + .flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..=10).map(move |k| sp.segment(i).eval(f64::from(k) / 10.0)))) + .map(off) + .fold(0.0, f64::max) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn minimum_smoothing_leaves_the_path_where_it_was() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let out = s.execute("object.path.smooth", &json!({"level": 0})).unwrap(); + assert_eq!(out["level"], json!(0.0)); + let after = path_of(&s, id); + // 0.1 % of the longer side (300 pt), both ways. + assert!(farthest(&before, &after) <= 0.3 + 1e-6 && farthest(&after, &before) <= 0.3 + 1e-6); +} + +#[test] +fn more_smoothing_removes_the_jags_and_keeps_the_shape() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let mut counts = vec![]; + for level in [25, 50, 100] { + let out = s.execute("object.path.smooth", &json!({ "level": level })).unwrap(); + let after = path_of(&s, id); + assert_eq!(out["after"].as_u64().unwrap() as usize, after.anchor_count()); + counts.push(after.anchor_count()); + // Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away. + assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after)); + // The end points of an open path stay put. + let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p)); + assert!(!after.subpaths[0].closed); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(counts[0] < 101 / 2 && counts[2] <= 10, "{counts:?}"); + assert!(counts.windows(2).all(|w| w[0] >= w[1]), "{counts:?}"); +} + +#[test] +fn auto_smooth_uses_the_middle_level() { + let mut s = session(); + let id = jagged_wave(&mut s); + let out = s.execute("object.path.smooth", &json!({"auto": true, "level": 0})).unwrap(); + assert_eq!(out["level"], json!(50.0)); + let auto = path_of(&s, id); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.path.smooth", &json!({"level": 50})).unwrap(); + assert_eq!(path_of(&s, id), auto); +} + +#[test] +fn closed_paths_and_compound_path_members_smooth_in_one_undo_step() { + let mut s = session(); + let outer = jagged_ring(&mut s, 300.0, 100.0, 2.0); + let inner = jagged_ring(&mut s, 300.0, 40.0, 1.0); + s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap(); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let (o, i) = (path_of(&s, outer), path_of(&s, inner)); + let steps = undo_len(&s); + let out = s.execute("object.path.smooth", &json!({"level": 100})).unwrap(); + assert_eq!(out["ids"].as_array().unwrap().len(), 2); + assert_eq!(undo_len(&s), steps + 1); + for id in [outer, inner] { + let now = path_of(&s, id); + assert!(now.subpaths[0].closed); + assert!(now.anchor_count() < 120 / 4, "{}", now.anchor_count()); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path_of(&s, outer), path_of(&s, inner)), (o, i)); +} + +#[test] +fn smooths_the_objects_given() { + let mut s = session(); + let a = jagged_wave(&mut s); + let b = jagged_ring(&mut s, 300.0, 50.0, 1.0); + let ring = path_of(&s, b); + // `b` is selected; `ids` names `a` instead. + let out = s.execute("object.path.smooth", &json!({"ids": [a.0], "level": 100})).unwrap(); + assert_eq!(out["ids"], json!([a.0])); + assert_eq!(path_of(&s, b), ring); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let steps = undo_len(&s); + for p in [json!({"level": "50"}), json!({"auto": true, "level": "max"}), json!({"auto": "yes"}), json!({"ids": "all"}), json!({"ids": [999_999]})] + { + assert!(s.execute("object.path.smooth", &p).is_err(), "{p}"); + } + assert_eq!((path_of(&s, id), undo_len(&s)), (before, steps)); + // Levels past the ends count as the ends. + assert_eq!(s.execute("object.path.smooth", &json!({"level": 250})).unwrap()["level"], json!(100.0)); +} diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 75a201753..94788f8d1 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -314,6 +314,55 @@ pub fn smooth(path: &PathData, amount: f64) -> PathData { out } +/// The Smooth slider's level Auto-Smooth picks, halfway between minimum and maximum smoothing. +/// VectorCraft's own rule: the help page says only that Auto-Smooth smooths the path automatically +/// (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html). +/// [`smooth_level`] already scales with each path's size, so one level suits every path. +pub const SMOOTH_AUTO_LEVEL: f64 = 50.0; + +/// Object › Path › Smooth at slider level `level`, from 0 (minimum smoothing: the path stays within +/// 0.1 % of its longer side of where it was) to 100 (maximum). The help page names only the two +/// ends; the rest is VectorCraft's own rule. Each subpath is refitted with curves as Simplify does, +/// but keeping a corner only where the path turns back on itself (so jags and kinks become curves), +/// within a tolerance from 0.1 % of the path's longer side at level 0 to 2.5 % at level 100. A refit +/// never has more anchor points than the subpath had: a shape whose corners the tolerance can't +/// round, such as a rectangle, stays as it is. Closed subpaths stay closed, with no kink left where +/// they start, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as +/// [`SMOOTH_AUTO_LEVEL`]. +pub fn smooth_level(path: &PathData, level: f64) -> PathData { + let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SMOOTH_AUTO_LEVEL }; + let size = path.bounds().map_or(0.0, |b| b.width().max(b.height())); + if !size.is_finite() { + return path.clone(); + } + let opts = SimplifyOptions { tolerance: size * (0.001 + 0.024 * level / 100.0), corner_angle_deg: SMOOTH_CORNER_DEG, straight_lines: false }; + let smoothed = |sp: &SubPath| { + let Some(mut fit) = simplify_subpath(sp, &opts).filter(|f| f != sp) else { return sp.clone() }; + smooth_seam(&mut fit); + fit + }; + PathData::new(path.subpaths.iter().map(smoothed).collect()) +} + +/// [`smooth_level`] keeps a corner only where the path turns by more than this (turns back). +const SMOOTH_CORNER_DEG: f64 = 179.0; + +/// A closed subpath's refit leaves its start anchor with the tangents of the original segments on +/// either side of it, a kink when the path had one there: turn it into a smooth anchor with the same +/// handle lengths, unless the path turns back there. +fn smooth_seam(sp: &mut SubPath) { + if !sp.closed { + return; + } + let Some(a) = sp.anchors.first_mut() else { return }; + let (Some(i), Some(o)) = (crate::fit::unit(a.p - a.h_in), crate::fit::unit(a.h_out - a.p)) else { return }; + if i.dot(o) < SMOOTH_CORNER_DEG.to_radians().cos() { + return; + } + let Some(dir) = crate::fit::unit(i + o) else { return }; + *a = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p)); +} + /// Remove anchors that can be deleted without changing the shape by more than `tolerance` /// (collinear corner points, duplicate points, and curve joints that one cubic can replace). pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index ed53ff833..4a656f98f 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,8 +20,8 @@ pub use boolean::{ try_unite_all_with, unite_all, unite_all_with, }; pub use edit::{ - AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, - remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid, + AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, + remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 3d4d9d761..9feaa79a8 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -1,7 +1,7 @@ use std::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; -use vectorcraft_geom::{FillRule, PathData, SubPath}; +use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath}; use vectorcraft_pathops::*; const NZ: FillRule = FillRule::NonZero; @@ -430,6 +430,21 @@ fn smooth_rounds_corners() { assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0)); } +/// Object › Path › Smooth: a rectangle, whose corners no curve rounds within the tolerance, stays as +/// it is even at maximum smoothing; a polygon of 16 sides becomes a closed curve of fewer anchors once the tolerance +/// passes its 0.96 pt sagitta, smooth all round. +#[test] +fn smooth_level_keeps_corners_it_cannot_round_and_curves_polygons() { + let r = rect(0.0, 0.0, 100.0, 50.0); + assert_eq!(smooth_level(&r, 100.0), r); + let pts: Vec = (0..16).map(|i| Point::new(50.0 * (f64::from(i) * PI / 8.0).cos(), 50.0 * (f64::from(i) * PI / 8.0).sin())).collect(); + let polygon = PathData::single(SubPath::polyline(&pts, true)); + let s = smooth_level(&polygon, SMOOTH_AUTO_LEVEL); + assert!(s.subpaths.len() == 1 && s.subpaths[0].closed && s.anchor_count() < 8, "{}", s.anchor_count()); + assert!(s.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "no kink, its start included: {s:?}"); + assert_eq!(smooth_level(&polygon, 0.0), polygon, "0.1 pt is less than the sagitta"); +} + #[test] fn remove_redundant_collinear_and_curve() { let p = PathData::single(SubPath::polyline( diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f564db75..0de1171bd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -327,8 +327,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_bands(app, ui, &xf, (full, rect), origin_preview, &t); } - // The task bar is not drawn while the Simplify slider shows. - if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { + // The task bar is not drawn while the Simplify or Smooth slider shows. + let strip = crate::dialogs::simplify_slider::showing(app) || crate::dialogs::smooth_slider::showing(app); + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !strip { task_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6920c8466..b28fc6e1a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -99,6 +99,7 @@ pub(crate) mod shapes; pub mod shortcut_import; pub(crate) mod simplify_slider; pub mod slices; +pub(crate) mod smooth_slider; pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; @@ -352,6 +353,7 @@ registry! { ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, + SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index caf898fdd..cf3866018 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -8,6 +8,8 @@ //! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). //! `vectorcraft_pathops::simplify_level_tolerance` maps levels to tolerances. //! +//! The strip itself ([`Strip`]) is shared with Object › Path › Smooth's slider (`smooth_slider`). +//! //! Fields: `level` (0..=100, the command's parameter). use egui::{Color32, CornerRadius, Order, Pos2, Sense, Stroke, vec2}; @@ -25,9 +27,122 @@ pub(crate) const KIND: &str = "simplifySlider"; pub(super) const SPEC: DialogSpec = DialogSpec { window: Some(show), confirm, preview: true, ..DialogSpec::FORM }; -/// The command the slider previews, and its undo label. -const CMD: &str = "object.path.simplify"; -const LABEL: &str = "Simplify"; +/// What the Simplify slider previews, and its buttons. +const STRIP: Strip = Strip { + kind: KIND, + cmd: "object.path.simplify", + label: "Simplify", + default_level: SIMPLIFY_LEVEL, + auto: ("Auto-Simplify", auto), + more_options: Some(more_options), +}; + +/// A UI command a button of a strip runs. +type Button = fn(&mut VectorcraftApp) -> Result; + +/// A slider strip under the selection that previews `cmd {level}` while it is open: its first +/// button picks the level `cmd {auto: true}` does, and an optional last one, More Options, opens a +/// dialog in its place. Enter or a click outside the strip keeps the result as one undo step, and +/// Escape puts the paths back. +pub(super) struct Strip { + /// The dialog kind. + pub kind: &'static str, + /// The command previewed. + pub cmd: &'static str, + /// The undo step's name, and the slider's. + pub label: &'static str, + /// The level when the field holds no number. + pub default_level: f64, + /// The auto button's tip, and what it runs. + pub auto: (&'static str, Button), + /// What More Options runs, when the strip has it. + pub more_options: Option