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}