Skip to main content

agg_gui/widgets/property_row/
editor.rs

1//! Editor metadata for property rows — what kind of inline / panel
2//! widget the property panel should mount for each field.
3//!
4//! Lives at the agg-gui layer because the schema is shared widget
5//! vocabulary: any host wanting MatterCAD-style reflection-driven
6//! property panels feeds its reflected fields into these types.
7//! Host-side `PropertyInfo` analogues (e.g. atomartist's `PropDef`)
8//! compose these types but keep their own value-typed defaults.
9//!
10//! ## Mapped from MatterCAD attributes
11//!
12//! | MatterCAD attribute             | agg-gui shape                       |
13//! | ------------------------------- | ----------------------------------- |
14//! | `[Slider(min, max, easing, …)]` | `EditorKind::Slider(NumberAttrs)`   |
15//! | `[MaxDecimalPlaces(n)]`         | `NumberAttrs::max_decimal_places`   |
16//! | `[Description("…")]`            | `NodeFieldAttrs::description`       |
17//! | `[ReadOnly(true)]` on string    | `EditorKind::StringReadOnly`        |
18//! | `[MultiLineEdit]`               | `EditorKind::StringMultiLine`       |
19//! | `[EnumDisplay(Mode = Tabs)]`    | `EditorKind::EnumTabs { variants }` |
20//! | `[EnumDisplay(Mode = Buttons)]` | `EditorKind::EnumButtons { … }`     |
21//! | `[EnumDisplay(Mode = IconRow)]` | `EditorKind::EnumIcons { … }`       |
22//! | `[HideFromEditor]`              | `NodeFieldAttrs::hidden`            |
23
24use std::sync::Arc;
25
26/// Editor hint for a property — how the property panel should render
27/// an editor for the current value.
28///
29/// The variants describe *intent*, not pixels — the row factory at
30/// `widgets/property_row` picks the concrete widget. Keeping the hint
31/// in the schema lets headless callers (tests, serialization, future
32/// inspector ports) reason about the editor shape without depending
33/// on the rendered widget tree.
34#[derive(Clone, Debug, PartialEq)]
35pub enum EditorKind {
36    /// Click-and-drag horizontally to edit a number. Default for
37    /// `Number` properties.
38    NumberDrag(NumberAttrs),
39    /// Horizontal slider between `min` and `max`. NodeDesigner's
40    /// "slider" widget and MatterCAD's `[Slider(...)]` map here.
41    Slider(NumberAttrs),
42    /// Boolean checkbox toggle.
43    Toggle,
44    /// Color swatch + picker.
45    ColorPicker,
46    /// 4×4 matrix — typically rendered as a compact button that opens
47    /// a translation/rotation/scale sub-panel.
48    Matrix,
49    /// Read-only text display. Used when a property's value isn't
50    /// directly editable on the node row (e.g. a derived value).
51    Display,
52    /// Editable single-line string. MatterCAD's default `string`
53    /// editor.
54    StringSingleLine,
55    /// Editable multi-line string. MatterCAD's `[MultiLineEdit]`.
56    StringMultiLine,
57    /// Word-wrapped read-only text. MatterCAD's `[ReadOnly(true)]`.
58    StringReadOnly,
59    /// Enum rendered as a dropdown combo box. The `variants` list is
60    /// the ordered set of allowed values (also the canonical display
61    /// labels). Current value is matched against one of these entries.
62    EnumDropdown { variants: Vec<Arc<str>> },
63    /// Enum rendered as a row of mutually-exclusive buttons.
64    EnumButtons { variants: Vec<Arc<str>> },
65    /// Enum rendered as a strip of icons — MatterCAD's
66    /// `[EnumDisplay(Mode = IconRow)]`.
67    ///
68    /// `icons[i]` is the *id* of the artwork for `variants[i]`, looked
69    /// up at paint time in [`crate::vector_icon`]'s host-registered
70    /// registry; an id with nothing registered under it falls back to
71    /// the variant's label, so the strip is never blank. Ids rather
72    /// than drawings keep `EditorKind` cheap to clone and comparable —
73    /// it travels through property snapshots many times per frame.
74    ///
75    /// The two vectors are parallel and `icons` may be shorter than
76    /// `variants` (the tail then renders as labels).
77    EnumIcons {
78        variants: Vec<Arc<str>>,
79        icons: Vec<Arc<str>>,
80    },
81    /// Enum rendered as a full-width tab strip — MatterCAD's
82    /// `EnumDisplay.Tabs`. Best for 2–5 variants the user switches
83    /// between at the top of a panel.
84    EnumTabs { variants: Vec<Arc<str>> },
85    /// Image picker / preview. MatterCAD's `ImageBufferPropertyEditor`.
86    Image,
87}
88
89impl EditorKind {
90    /// `NumberDrag` editor with `[min, max]` range.
91    pub fn drag_range(min: f64, max: f64) -> Self {
92        EditorKind::NumberDrag(NumberAttrs {
93            min: Some(min),
94            max: Some(max),
95            ..Default::default()
96        })
97    }
98
99    /// `Slider` editor with `[min, max]` range.
100    pub fn slider_range(min: f64, max: f64) -> Self {
101        EditorKind::Slider(NumberAttrs {
102            min: Some(min),
103            max: Some(max),
104            ..Default::default()
105        })
106    }
107
108    /// Inclusive numeric range when this editor is numeric, else `None`.
109    pub fn numeric_range(&self) -> (Option<f64>, Option<f64>) {
110        match self {
111            EditorKind::NumberDrag(a) | EditorKind::Slider(a) => (a.min, a.max),
112            _ => (None, None),
113        }
114    }
115
116    /// The ordered variant list when this editor is one of the enum
117    /// presentations (dropdown / buttons / tabs), else `None`. Hosts use
118    /// it to map a selected index back to the value they store.
119    pub fn enum_variants(&self) -> Option<&[Arc<str>]> {
120        match self {
121            EditorKind::EnumDropdown { variants }
122            | EditorKind::EnumButtons { variants }
123            | EditorKind::EnumTabs { variants }
124            | EditorKind::EnumIcons { variants, .. } => Some(variants.as_slice()),
125            _ => None,
126        }
127    }
128
129    /// An [`EnumIcons`](EditorKind::EnumIcons) editor from parallel
130    /// `(variant, icon id)` pairs — the shape hosts usually have on
131    /// hand, without building two vectors by hand.
132    pub fn enum_icons<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
133        let (variants, icons) = pairs
134            .into_iter()
135            .map(|(v, i)| (Arc::from(v), Arc::from(i)))
136            .unzip();
137        EditorKind::EnumIcons { variants, icons }
138    }
139
140    /// The icon ids of an [`EnumIcons`](EditorKind::EnumIcons) editor,
141    /// else `None`. Parallel to [`enum_variants`](Self::enum_variants).
142    pub fn enum_icon_ids(&self) -> Option<&[Arc<str>]> {
143        match self {
144            EditorKind::EnumIcons { icons, .. } => Some(icons.as_slice()),
145            _ => None,
146        }
147    }
148
149    /// Numeric editor attributes when this editor is numeric.
150    pub fn number_attrs(&self) -> Option<&NumberAttrs> {
151        match self {
152            EditorKind::NumberDrag(a) | EditorKind::Slider(a) => Some(a),
153            _ => None,
154        }
155    }
156}
157
158impl Default for EditorKind {
159    fn default() -> Self {
160        EditorKind::Display
161    }
162}
163
164/// Numeric editor attributes — used by [`EditorKind::NumberDrag`] and
165/// [`EditorKind::Slider`]. Mirrors NodeDesigner's `addWidget("slider", ...)`
166/// option bag and MatterCAD's `[Slider]` / `[MaxDecimalPlaces]` attributes.
167#[derive(Clone, Debug, Default, PartialEq)]
168pub struct NumberAttrs {
169    /// Inclusive minimum.
170    pub min: Option<f64>,
171    /// Inclusive maximum.
172    pub max: Option<f64>,
173    /// Drag step (smallest delta per pixel of motion). `None` lets the
174    /// editor pick a sensible default for the range.
175    pub step: Option<f64>,
176    /// Display + clamp as an integer.
177    pub integer: bool,
178    /// Power-of-N easing applied to slider drag deltas. NodeDesigner's
179    /// `easeIn: 2` maps here.
180    pub ease_in: Option<f64>,
181    /// Snap drag deltas to a screen-space grid. NodeDesigner's
182    /// `useSnapGrid` maps here.
183    pub snap_grid: bool,
184    /// Limit display precision. MatterCAD's `[MaxDecimalPlaces(n)]` —
185    /// the stored value keeps full precision; this only affects
186    /// rendering.
187    pub max_decimal_places: Option<u8>,
188}
189
190impl NumberAttrs {
191    pub fn with_range(min: f64, max: f64) -> Self {
192        Self {
193            min: Some(min),
194            max: Some(max),
195            ..Default::default()
196        }
197    }
198    pub fn integer(mut self) -> Self {
199        self.integer = true;
200        self
201    }
202    pub fn with_step(mut self, step: f64) -> Self {
203        self.step = Some(step);
204        self
205    }
206    pub fn with_ease_in(mut self, e: f64) -> Self {
207        self.ease_in = Some(e);
208        self
209    }
210    pub fn with_snap_grid(mut self) -> Self {
211        self.snap_grid = true;
212        self
213    }
214    pub fn with_decimal_places(mut self, n: u8) -> Self {
215        self.max_decimal_places = Some(n);
216        self
217    }
218}
219
220/// Conditional visibility for a field — the data-driven analogue of
221/// MatterCAD's `IPropertyGridModifier.UpdateControls(change)` hook.
222///
223/// The host knows which sibling boolean property gates the
224/// `AdvancedOn` / `AdvancedOff` rows (typically a `bool` named
225/// `advanced`); the UI layer filters rows before rendering based on
226/// the live value of that toggle.
227#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
228pub enum VisibleWhen {
229    /// Always render (default).
230    #[default]
231    Always,
232    /// Render only when the node's `advanced` toggle is on. MatterCAD's
233    /// `IPropertyGridModifier.UpdateControls` pattern for advanced
234    /// rows.
235    AdvancedOn,
236    /// Render only when the node's `advanced` toggle is off. Used by
237    /// the easy-mode hint message that nudges the user toward
238    /// Advanced.
239    AdvancedOff,
240    /// Never render. MatterCAD's `[HideFromEditor]`.
241    Never,
242}
243
244/// Field-level metadata — declared once per reflected struct field and
245/// consumed both by the host's `PropDef`-style binding type (which
246/// folds these into the property store) and by the property panel
247/// when it renders the field's editor + label.
248#[derive(Clone, Debug, Default)]
249pub struct NodeFieldAttrs {
250    pub label: Option<Arc<str>>,
251    pub editor: EditorKind,
252    /// When `Some(socket_name)`, the field is paired with the input
253    /// socket of that name (host-side concept): the canvas draws the
254    /// field's inline editor on the socket's row, and the editor is
255    /// hidden when the socket is connected. Hosts without an input-
256    /// socket concept ignore this.
257    pub bound_input: Option<Arc<str>>,
258    /// Free-text description shown in tooltips / the property-panel
259    /// detail view. MatterCAD's `[Description("…")]`.
260    pub description: Option<Arc<str>>,
261    /// Visibility gate — Always, AdvancedOn, AdvancedOff, or Never.
262    /// The UI filters rows by combining this with the live "advanced"
263    /// toggle value.
264    pub visible_when: VisibleWhen,
265}
266
267impl NodeFieldAttrs {
268    pub fn new() -> Self {
269        Self::default()
270    }
271    pub fn with_label(mut self, label: impl Into<Arc<str>>) -> Self {
272        self.label = Some(label.into());
273        self
274    }
275    pub fn with_editor(mut self, editor: EditorKind) -> Self {
276        self.editor = editor;
277        self
278    }
279    pub fn bound_to(mut self, socket: impl Into<Arc<str>>) -> Self {
280        self.bound_input = Some(socket.into());
281        self
282    }
283    pub fn with_description(mut self, text: impl Into<Arc<str>>) -> Self {
284        self.description = Some(text.into());
285        self
286    }
287    /// Set the conditional visibility gate. Same trio as MatterCAD's
288    /// `[HideFromEditor]` + `IPropertyGridModifier.UpdateControls`.
289    pub fn visible_when(mut self, when: VisibleWhen) -> Self {
290        self.visible_when = when;
291        self
292    }
293    /// Shorthand for `visible_when(VisibleWhen::AdvancedOn)` — the
294    /// common case of "this row is only relevant after the user opens
295    /// Advanced".
296    pub fn advanced(mut self) -> Self {
297        self.visible_when = VisibleWhen::AdvancedOn;
298        self
299    }
300    /// Shorthand for `visible_when(VisibleWhen::AdvancedOff)` — used
301    /// by easy-mode hint messages that disappear once Advanced is on.
302    pub fn easy_only(mut self) -> Self {
303        self.visible_when = VisibleWhen::AdvancedOff;
304        self
305    }
306    /// Shorthand for `visible_when(VisibleWhen::Never)`.
307    pub fn hidden(mut self) -> Self {
308        self.visible_when = VisibleWhen::Never;
309        self
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn slider_range_helper_sets_min_max() {
319        let k = EditorKind::slider_range(1.0, 400.0);
320        match k {
321            EditorKind::Slider(a) => {
322                assert_eq!(a.min, Some(1.0));
323                assert_eq!(a.max, Some(400.0));
324            }
325            other => panic!("expected Slider, got {:?}", other),
326        }
327    }
328
329    #[test]
330    fn default_editor_is_display() {
331        assert!(matches!(EditorKind::default(), EditorKind::Display));
332    }
333
334    #[test]
335    fn number_attrs_builder_chains() {
336        let a = NumberAttrs::with_range(0.0, 360.0)
337            .integer()
338            .with_step(1.0)
339            .with_decimal_places(0)
340            .with_ease_in(2.0)
341            .with_snap_grid();
342        assert_eq!(a.min, Some(0.0));
343        assert_eq!(a.max, Some(360.0));
344        assert!(a.integer);
345        assert_eq!(a.step, Some(1.0));
346        assert_eq!(a.max_decimal_places, Some(0));
347        assert_eq!(a.ease_in, Some(2.0));
348        assert!(a.snap_grid);
349    }
350
351    #[test]
352    fn node_field_attrs_builder_chains() {
353        let a = NodeFieldAttrs::new()
354            .with_label("Diameter")
355            .with_editor(EditorKind::slider_range(1.0, 400.0))
356            .with_description("Width across.")
357            .advanced();
358        assert_eq!(a.label.as_deref().map(|x| x.as_ref()), Some("Diameter"));
359        assert!(matches!(a.editor, EditorKind::Slider(_)));
360        assert!(a
361            .description
362            .as_deref()
363            .map(|x| x.contains("Width"))
364            .unwrap_or(false));
365        assert_eq!(a.visible_when, VisibleWhen::AdvancedOn);
366    }
367
368    #[test]
369    fn visible_when_shorthands_set_expected_variant() {
370        assert_eq!(NodeFieldAttrs::new().visible_when, VisibleWhen::Always);
371        assert_eq!(
372            NodeFieldAttrs::new().advanced().visible_when,
373            VisibleWhen::AdvancedOn
374        );
375        assert_eq!(
376            NodeFieldAttrs::new().easy_only().visible_when,
377            VisibleWhen::AdvancedOff
378        );
379        assert_eq!(
380            NodeFieldAttrs::new().hidden().visible_when,
381            VisibleWhen::Never
382        );
383    }
384
385    #[test]
386    fn numeric_range_is_none_for_non_numeric() {
387        assert_eq!(EditorKind::Toggle.numeric_range(), (None, None));
388        assert_eq!(EditorKind::ColorPicker.numeric_range(), (None, None));
389        assert_eq!(EditorKind::StringSingleLine.numeric_range(), (None, None));
390    }
391
392    /// The icon strip is an enum presentation like any other: its
393    /// variants must come back through the same accessor every host
394    /// uses, or an icon row would silently stop being clickable.
395    #[test]
396    fn enum_icons_exposes_variants_and_icon_ids() {
397        let k = EditorKind::enum_icons([
398            ("Combine", "boolean.combine"),
399            ("Subtract", "boolean.subtract"),
400        ]);
401        let variants = k.enum_variants().expect("icon rows are enum rows");
402        assert_eq!(variants.len(), 2);
403        assert_eq!(variants[1].as_ref(), "Subtract");
404        let icons = k.enum_icon_ids().expect("icon rows carry icon ids");
405        assert_eq!(icons[0].as_ref(), "boolean.combine");
406    }
407
408    #[test]
409    fn non_icon_editors_have_no_icon_ids() {
410        assert!(EditorKind::EnumButtons {
411            variants: vec!["A".into()]
412        }
413        .enum_icon_ids()
414        .is_none());
415        assert!(EditorKind::Toggle.enum_icon_ids().is_none());
416    }
417
418    #[test]
419    fn enum_variants_round_trip() {
420        let k = EditorKind::EnumTabs {
421            variants: vec!["Easy".into(), "Advanced".into()],
422        };
423        if let EditorKind::EnumTabs { variants } = k {
424            assert_eq!(variants.len(), 2);
425            assert_eq!(variants[0].as_ref(), "Easy");
426        } else {
427            panic!("expected EnumTabs");
428        }
429    }
430}