Effect dialogs keep a typed distance within the effect's range (#4637)

This commit is contained in:
datatimp
2026-10-30 09:01:11 +07:00
committed by bflatastic
parent afc6255ac2
commit 6a80e43b68
5 changed files with 73 additions and 2 deletions
+14
View File
@@ -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<std::ops::RangeInclusive<f64>> {
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::<f64>().ok()?, hi.trim_end_matches(',').parse::<f64>().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: &[] };
+1 -1
View File
@@ -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)]
+14
View File
@@ -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);
}
+17 -1
View File
@@ -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<std::ops::RangeInclusive<f64>>,
) -> 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) {
+27
View File
@@ -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<egui::Event>| {
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}");
}
}