diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index e000b930f..d10d931d6 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -42,6 +42,20 @@ pub fn is_length(id: &str, key: &str, relative: bool) -> bool { catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) } +/// The range in points that distance parameter `key` of effect `id` is kept within, as its +/// parameter documentation gives it (`{maxRadius: pt 4..127 (8), …}`); `None` when it gives none. +pub fn length_range(id: &str, key: &str) -> Option> { + let doc = catalog_index().get(id)?.params; + let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' '); + let rest = doc.match_indices(key).map(|(at, _)| at).filter(|at| starts(*at)).find_map(|at| { + let rest = doc.get(at + key.len()..)?; + rest.strip_prefix(": pt ").or_else(|| rest.strip_prefix("?: pt ")) + })?; + let (lo, hi) = rest.split_whitespace().next()?.split_once("..")?; + let (lo, hi) = (lo.parse::().ok()?, hi.trim_end_matches(',').parse::().ok()?); + (lo.is_finite() && hi.is_finite() && lo <= hi).then_some(lo..=hi) +} + /// The distance parameters of effect `id` (none for unknown effects). fn lengths_of(id: &str) -> Lengths { let always = |always| Lengths { always, absolute: &[] }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b6db2f4d9..a732e2dff 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -94,7 +94,7 @@ mod catalog; mod geometry; mod params; -pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, plugin_effects}; +pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects}; pub(crate) use geometry::{Budget, apply_one}; pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; #[cfg(test)] diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0241fa8b9..198da329e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1207,3 +1207,17 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { let small = Node::new(NodeId(0), NodeKind::Repeat(RepeatSpec::mirror(vec![instance("Small")], 90.0, 5.0))); assert_eq!(expand_live(Some(&d), &small).len(), 2); } + +#[test] +fn a_distance_parameters_range_comes_from_its_documentation() { + assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0)); + // The effect applies the range's floor to a radius below it. + assert_eq!( + crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 1})), + crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 4})) + ); + // A distance documented without a range, and unknown effects and parameters, have none. + assert_eq!(length_range("path.offsetPath", "offset"), None); + assert_eq!(length_range("pixelate.colorHalftone", "channel1"), None); + assert_eq!(length_range("no.such", "maxRadius"), None); +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index b82fe54f8..941687ae4 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -44,8 +44,22 @@ pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: /// "5 mm", wins), kept in points (a string with a unit, "12 pt", also reads). Returns true when it /// changed. pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool { + length_within(ui, d, key, unit, width, None) +} + +/// [`length`] whose value is kept within `range` (points): a value typed outside it becomes the +/// nearer end, so the field shows the distance the command applies (#4637). +pub(super) fn length_within( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + unit: Unit, + width: f32, + range: Option>, +) -> bool { let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0)); let Some(v) = crate::widgets::num_field(ui, ("dlg-len", key), value, unit, width) else { return false }; + let v = range.map_or(v, |r| v.max(*r.start()).min(*r.end())); d.fields.insert(key.into(), json!(v)); true } @@ -365,7 +379,9 @@ pub(super) fn param_fields( return; } if is_length(&k) { - changed |= length(ui, d, &k, unit, 140.0); + // An effect's documented range keeps the field to the distance it applies. + let range = vectorcraft_effects::length_range(&d.str("__effect"), &k); + changed |= length_within(ui, d, &k, unit, 140.0, range); return; } if let Some(options) = choices(&k) { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 66f37e10f..ae9fb664f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2486,3 +2486,30 @@ fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() { app.run("edit.redo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, after); } + +/// An effect dialog keeps a distance typed outside the effect's range to the range, so the field +/// shows what the effect applies: Color Halftone's Max Radius below 4 pt is 4 pt (#4637). +#[test] +fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { + let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + for (typed, applied) in [("1 pt", 4.0), ("0.5 px", 4.0), ("200 pt", 127.0), ("10 pt", 10.0)] { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + // Max Radius, the first field, is focused: type over it and press Enter. + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text(typed.into())]); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none(), "{typed}"); + let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + assert_eq!(node.appearance.effects.last().map(|e| e.params["maxRadius"].clone()), Some(json!(applied)), "{typed}"); + } +}