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::EnumDropdown { … }` |
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 full-width tab strip — MatterCAD's
66 /// `EnumDisplay.Tabs`. Best for 2–5 variants the user switches
67 /// between at the top of a panel.
68 EnumTabs { variants: Vec<Arc<str>> },
69 /// Image picker / preview. MatterCAD's `ImageBufferPropertyEditor`.
70 Image,
71}
72
73impl EditorKind {
74 /// `NumberDrag` editor with `[min, max]` range.
75 pub fn drag_range(min: f64, max: f64) -> Self {
76 EditorKind::NumberDrag(NumberAttrs {
77 min: Some(min),
78 max: Some(max),
79 ..Default::default()
80 })
81 }
82
83 /// `Slider` editor with `[min, max]` range.
84 pub fn slider_range(min: f64, max: f64) -> Self {
85 EditorKind::Slider(NumberAttrs {
86 min: Some(min),
87 max: Some(max),
88 ..Default::default()
89 })
90 }
91
92 /// Inclusive numeric range when this editor is numeric, else `None`.
93 pub fn numeric_range(&self) -> (Option<f64>, Option<f64>) {
94 match self {
95 EditorKind::NumberDrag(a) | EditorKind::Slider(a) => (a.min, a.max),
96 _ => (None, None),
97 }
98 }
99
100 /// Numeric editor attributes when this editor is numeric.
101 pub fn number_attrs(&self) -> Option<&NumberAttrs> {
102 match self {
103 EditorKind::NumberDrag(a) | EditorKind::Slider(a) => Some(a),
104 _ => None,
105 }
106 }
107}
108
109impl Default for EditorKind {
110 fn default() -> Self {
111 EditorKind::Display
112 }
113}
114
115/// Numeric editor attributes — used by [`EditorKind::NumberDrag`] and
116/// [`EditorKind::Slider`]. Mirrors NodeDesigner's `addWidget("slider", ...)`
117/// option bag and MatterCAD's `[Slider]` / `[MaxDecimalPlaces]` attributes.
118#[derive(Clone, Debug, Default, PartialEq)]
119pub struct NumberAttrs {
120 /// Inclusive minimum.
121 pub min: Option<f64>,
122 /// Inclusive maximum.
123 pub max: Option<f64>,
124 /// Drag step (smallest delta per pixel of motion). `None` lets the
125 /// editor pick a sensible default for the range.
126 pub step: Option<f64>,
127 /// Display + clamp as an integer.
128 pub integer: bool,
129 /// Power-of-N easing applied to slider drag deltas. NodeDesigner's
130 /// `easeIn: 2` maps here.
131 pub ease_in: Option<f64>,
132 /// Snap drag deltas to a screen-space grid. NodeDesigner's
133 /// `useSnapGrid` maps here.
134 pub snap_grid: bool,
135 /// Limit display precision. MatterCAD's `[MaxDecimalPlaces(n)]` —
136 /// the stored value keeps full precision; this only affects
137 /// rendering.
138 pub max_decimal_places: Option<u8>,
139}
140
141impl NumberAttrs {
142 pub fn with_range(min: f64, max: f64) -> Self {
143 Self {
144 min: Some(min),
145 max: Some(max),
146 ..Default::default()
147 }
148 }
149 pub fn integer(mut self) -> Self {
150 self.integer = true;
151 self
152 }
153 pub fn with_step(mut self, step: f64) -> Self {
154 self.step = Some(step);
155 self
156 }
157 pub fn with_ease_in(mut self, e: f64) -> Self {
158 self.ease_in = Some(e);
159 self
160 }
161 pub fn with_snap_grid(mut self) -> Self {
162 self.snap_grid = true;
163 self
164 }
165 pub fn with_decimal_places(mut self, n: u8) -> Self {
166 self.max_decimal_places = Some(n);
167 self
168 }
169}
170
171/// Conditional visibility for a field — the data-driven analogue of
172/// MatterCAD's `IPropertyGridModifier.UpdateControls(change)` hook.
173///
174/// The host knows which sibling boolean property gates the
175/// `AdvancedOn` / `AdvancedOff` rows (typically a `bool` named
176/// `advanced`); the UI layer filters rows before rendering based on
177/// the live value of that toggle.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub enum VisibleWhen {
180 /// Always render (default).
181 #[default]
182 Always,
183 /// Render only when the node's `advanced` toggle is on. MatterCAD's
184 /// `IPropertyGridModifier.UpdateControls` pattern for advanced
185 /// rows.
186 AdvancedOn,
187 /// Render only when the node's `advanced` toggle is off. Used by
188 /// the easy-mode hint message that nudges the user toward
189 /// Advanced.
190 AdvancedOff,
191 /// Never render. MatterCAD's `[HideFromEditor]`.
192 Never,
193}
194
195/// Field-level metadata — declared once per reflected struct field and
196/// consumed both by the host's `PropDef`-style binding type (which
197/// folds these into the property store) and by the property panel
198/// when it renders the field's editor + label.
199#[derive(Clone, Debug, Default)]
200pub struct NodeFieldAttrs {
201 pub label: Option<Arc<str>>,
202 pub editor: EditorKind,
203 /// When `Some(socket_name)`, the field is paired with the input
204 /// socket of that name (host-side concept): the canvas draws the
205 /// field's inline editor on the socket's row, and the editor is
206 /// hidden when the socket is connected. Hosts without an input-
207 /// socket concept ignore this.
208 pub bound_input: Option<Arc<str>>,
209 /// Free-text description shown in tooltips / the property-panel
210 /// detail view. MatterCAD's `[Description("…")]`.
211 pub description: Option<Arc<str>>,
212 /// Visibility gate — Always, AdvancedOn, AdvancedOff, or Never.
213 /// The UI filters rows by combining this with the live "advanced"
214 /// toggle value.
215 pub visible_when: VisibleWhen,
216}
217
218impl NodeFieldAttrs {
219 pub fn new() -> Self {
220 Self::default()
221 }
222 pub fn with_label(mut self, label: impl Into<Arc<str>>) -> Self {
223 self.label = Some(label.into());
224 self
225 }
226 pub fn with_editor(mut self, editor: EditorKind) -> Self {
227 self.editor = editor;
228 self
229 }
230 pub fn bound_to(mut self, socket: impl Into<Arc<str>>) -> Self {
231 self.bound_input = Some(socket.into());
232 self
233 }
234 pub fn with_description(mut self, text: impl Into<Arc<str>>) -> Self {
235 self.description = Some(text.into());
236 self
237 }
238 /// Set the conditional visibility gate. Same trio as MatterCAD's
239 /// `[HideFromEditor]` + `IPropertyGridModifier.UpdateControls`.
240 pub fn visible_when(mut self, when: VisibleWhen) -> Self {
241 self.visible_when = when;
242 self
243 }
244 /// Shorthand for `visible_when(VisibleWhen::AdvancedOn)` — the
245 /// common case of "this row is only relevant after the user opens
246 /// Advanced".
247 pub fn advanced(mut self) -> Self {
248 self.visible_when = VisibleWhen::AdvancedOn;
249 self
250 }
251 /// Shorthand for `visible_when(VisibleWhen::AdvancedOff)` — used
252 /// by easy-mode hint messages that disappear once Advanced is on.
253 pub fn easy_only(mut self) -> Self {
254 self.visible_when = VisibleWhen::AdvancedOff;
255 self
256 }
257 /// Shorthand for `visible_when(VisibleWhen::Never)`.
258 pub fn hidden(mut self) -> Self {
259 self.visible_when = VisibleWhen::Never;
260 self
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn slider_range_helper_sets_min_max() {
270 let k = EditorKind::slider_range(1.0, 400.0);
271 match k {
272 EditorKind::Slider(a) => {
273 assert_eq!(a.min, Some(1.0));
274 assert_eq!(a.max, Some(400.0));
275 }
276 other => panic!("expected Slider, got {:?}", other),
277 }
278 }
279
280 #[test]
281 fn default_editor_is_display() {
282 assert!(matches!(EditorKind::default(), EditorKind::Display));
283 }
284
285 #[test]
286 fn number_attrs_builder_chains() {
287 let a = NumberAttrs::with_range(0.0, 360.0)
288 .integer()
289 .with_step(1.0)
290 .with_decimal_places(0)
291 .with_ease_in(2.0)
292 .with_snap_grid();
293 assert_eq!(a.min, Some(0.0));
294 assert_eq!(a.max, Some(360.0));
295 assert!(a.integer);
296 assert_eq!(a.step, Some(1.0));
297 assert_eq!(a.max_decimal_places, Some(0));
298 assert_eq!(a.ease_in, Some(2.0));
299 assert!(a.snap_grid);
300 }
301
302 #[test]
303 fn node_field_attrs_builder_chains() {
304 let a = NodeFieldAttrs::new()
305 .with_label("Diameter")
306 .with_editor(EditorKind::slider_range(1.0, 400.0))
307 .with_description("Width across.")
308 .advanced();
309 assert_eq!(a.label.as_deref().map(|x| x.as_ref()), Some("Diameter"));
310 assert!(matches!(a.editor, EditorKind::Slider(_)));
311 assert!(a
312 .description
313 .as_deref()
314 .map(|x| x.contains("Width"))
315 .unwrap_or(false));
316 assert_eq!(a.visible_when, VisibleWhen::AdvancedOn);
317 }
318
319 #[test]
320 fn visible_when_shorthands_set_expected_variant() {
321 assert_eq!(NodeFieldAttrs::new().visible_when, VisibleWhen::Always);
322 assert_eq!(
323 NodeFieldAttrs::new().advanced().visible_when,
324 VisibleWhen::AdvancedOn
325 );
326 assert_eq!(
327 NodeFieldAttrs::new().easy_only().visible_when,
328 VisibleWhen::AdvancedOff
329 );
330 assert_eq!(
331 NodeFieldAttrs::new().hidden().visible_when,
332 VisibleWhen::Never
333 );
334 }
335
336 #[test]
337 fn numeric_range_is_none_for_non_numeric() {
338 assert_eq!(EditorKind::Toggle.numeric_range(), (None, None));
339 assert_eq!(EditorKind::ColorPicker.numeric_range(), (None, None));
340 assert_eq!(EditorKind::StringSingleLine.numeric_range(), (None, None));
341 }
342
343 #[test]
344 fn enum_variants_round_trip() {
345 let k = EditorKind::EnumTabs {
346 variants: vec!["Easy".into(), "Advanced".into()],
347 };
348 if let EditorKind::EnumTabs { variants } = k {
349 assert_eq!(variants.len(), 2);
350 assert_eq!(variants[0].as_ref(), "Easy");
351 } else {
352 panic!("expected EnumTabs");
353 }
354 }
355}