Skip to main content

proof_engine/editor/
inspector.rs

1//! Inspector panel — property editor for any scene object.
2//!
3//! The inspector reflects the fields of whichever entity/glyph is selected and
4//! lets the user edit them in real time.  All edits produce `SetPropertyCommand`
5//! entries so they can be undone.
6
7use glam::{Vec2, Vec3, Vec4};
8use std::collections::HashMap;
9
10// ─────────────────────────────────────────────────────────────────────────────
11// Primitive field types
12// ─────────────────────────────────────────────────────────────────────────────
13
14/// A boolean checkbox field.
15#[derive(Debug, Clone)]
16pub struct BoolField {
17    pub name: String,
18    pub value: bool,
19    pub tooltip: Option<String>,
20}
21
22impl BoolField {
23    pub fn new(name: impl Into<String>, value: bool) -> Self {
24        Self { name: name.into(), value, tooltip: None }
25    }
26    pub fn with_tooltip(mut self, tip: impl Into<String>) -> Self {
27        self.tooltip = Some(tip.into());
28        self
29    }
30    pub fn toggle(&mut self) {
31        self.value = !self.value;
32    }
33    pub fn render_ascii(&self) -> String {
34        let check = if self.value { "[x]" } else { "[ ]" };
35        format!("{} {}", check, self.name)
36    }
37}
38
39/// An integer spinner field.
40#[derive(Debug, Clone)]
41pub struct IntField {
42    pub name: String,
43    pub value: i64,
44    pub min: Option<i64>,
45    pub max: Option<i64>,
46    pub step: i64,
47}
48
49impl IntField {
50    pub fn new(name: impl Into<String>, value: i64) -> Self {
51        Self { name: name.into(), value, min: None, max: None, step: 1 }
52    }
53    pub fn with_range(mut self, min: i64, max: i64) -> Self {
54        self.min = Some(min);
55        self.max = Some(max);
56        self
57    }
58    pub fn increment(&mut self) {
59        self.value += self.step;
60        if let Some(m) = self.max { self.value = self.value.min(m); }
61    }
62    pub fn decrement(&mut self) {
63        self.value -= self.step;
64        if let Some(m) = self.min { self.value = self.value.max(m); }
65    }
66    pub fn set(&mut self, v: i64) {
67        let v = if let Some(m) = self.min { v.max(m) } else { v };
68        let v = if let Some(m) = self.max { v.min(m) } else { v };
69        self.value = v;
70    }
71    pub fn render_ascii(&self) -> String {
72        format!("{}: {}", self.name, self.value)
73    }
74}
75
76/// A floating-point number field.
77#[derive(Debug, Clone)]
78pub struct FloatField {
79    pub name: String,
80    pub value: f64,
81    pub min: Option<f64>,
82    pub max: Option<f64>,
83    pub step: f64,
84    pub precision: usize,
85}
86
87impl FloatField {
88    pub fn new(name: impl Into<String>, value: f64) -> Self {
89        Self { name: name.into(), value, min: None, max: None, step: 0.1, precision: 3 }
90    }
91    pub fn with_range(mut self, min: f64, max: f64) -> Self {
92        self.min = Some(min); self.max = Some(max); self
93    }
94    pub fn with_step(mut self, step: f64) -> Self {
95        self.step = step; self
96    }
97    pub fn set(&mut self, v: f64) {
98        let v = if let Some(m) = self.min { v.max(m) } else { v };
99        let v = if let Some(m) = self.max { v.min(m) } else { v };
100        self.value = v;
101    }
102    pub fn render_ascii(&self) -> String {
103        format!("{}: {:.prec$}", self.name, self.value, prec = self.precision)
104    }
105}
106
107/// A text input field.
108#[derive(Debug, Clone)]
109pub struct StringField {
110    pub name: String,
111    pub value: String,
112    pub max_len: usize,
113    pub multiline: bool,
114    pub placeholder: String,
115}
116
117impl StringField {
118    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
119        Self {
120            name: name.into(),
121            value: value.into(),
122            max_len: 256,
123            multiline: false,
124            placeholder: String::new(),
125        }
126    }
127    pub fn set(&mut self, v: impl Into<String>) {
128        let v: String = v.into();
129        self.value = if v.len() > self.max_len { v[..self.max_len].to_string() } else { v };
130    }
131    pub fn push_char(&mut self, c: char) {
132        if self.value.len() < self.max_len {
133            self.value.push(c);
134        }
135    }
136    pub fn pop_char(&mut self) {
137        self.value.pop();
138    }
139    pub fn render_ascii(&self) -> String {
140        format!("{}: \"{}\"", self.name, self.value)
141    }
142}
143
144/// A 2-component vector field.
145#[derive(Debug, Clone)]
146pub struct Vec2Field {
147    pub name: String,
148    pub value: Vec2,
149    pub min: Option<f32>,
150    pub max: Option<f32>,
151}
152
153impl Vec2Field {
154    pub fn new(name: impl Into<String>, value: Vec2) -> Self {
155        Self { name: name.into(), value, min: None, max: None }
156    }
157    pub fn set(&mut self, v: Vec2) {
158        self.value = v;
159        if let Some(lo) = self.min { self.value = self.value.max(Vec2::splat(lo)); }
160        if let Some(hi) = self.max { self.value = self.value.min(Vec2::splat(hi)); }
161    }
162    pub fn render_ascii(&self) -> String {
163        format!("{}: ({:.3}, {:.3})", self.name, self.value.x, self.value.y)
164    }
165}
166
167/// A 3-component vector field.
168#[derive(Debug, Clone)]
169pub struct Vec3Field {
170    pub name: String,
171    pub value: Vec3,
172    pub min: Option<f32>,
173    pub max: Option<f32>,
174}
175
176impl Vec3Field {
177    pub fn new(name: impl Into<String>, value: Vec3) -> Self {
178        Self { name: name.into(), value, min: None, max: None }
179    }
180    pub fn set(&mut self, v: Vec3) {
181        self.value = v;
182        if let Some(lo) = self.min { self.value = self.value.max(Vec3::splat(lo)); }
183        if let Some(hi) = self.max { self.value = self.value.min(Vec3::splat(hi)); }
184    }
185    pub fn render_ascii(&self) -> String {
186        format!(
187            "{}: ({:.3}, {:.3}, {:.3})",
188            self.name, self.value.x, self.value.y, self.value.z
189        )
190    }
191}
192
193/// A 4-component vector field.
194#[derive(Debug, Clone)]
195pub struct Vec4Field {
196    pub name: String,
197    pub value: Vec4,
198}
199
200impl Vec4Field {
201    pub fn new(name: impl Into<String>, value: Vec4) -> Self {
202        Self { name: name.into(), value }
203    }
204    pub fn set(&mut self, v: Vec4) {
205        self.value = v;
206    }
207    pub fn render_ascii(&self) -> String {
208        format!(
209            "{}: ({:.3}, {:.3}, {:.3}, {:.3})",
210            self.name,
211            self.value.x,
212            self.value.y,
213            self.value.z,
214            self.value.w,
215        )
216    }
217}
218
219/// An RGBA colour picker.
220#[derive(Debug, Clone)]
221pub struct ColorField {
222    pub name: String,
223    pub color: Vec4,     // RGBA in [0, 1]
224    pub popup_open: bool,
225    // HSV cache for the colour wheel
226    hue: f32,
227    saturation: f32,
228    brightness: f32,
229}
230
231impl ColorField {
232    pub fn new(name: impl Into<String>, color: Vec4) -> Self {
233        let (h, s, b) = rgb_to_hsv(color.x, color.y, color.z);
234        Self { name: name.into(), color, popup_open: false, hue: h, saturation: s, brightness: b }
235    }
236    pub fn set_rgb(&mut self, r: f32, g: f32, b_: f32) {
237        let (h, s, b) = rgb_to_hsv(r, g, b_);
238        self.color.x = r; self.color.y = g; self.color.z = b_;
239        self.hue = h; self.saturation = s; self.brightness = b;
240    }
241    pub fn set_hsv(&mut self, h: f32, s: f32, v: f32) {
242        let (r, g, b) = hsv_to_rgb(h, s, v);
243        self.hue = h; self.saturation = s; self.brightness = v;
244        self.color.x = r; self.color.y = g; self.color.z = b;
245    }
246    pub fn set_alpha(&mut self, a: f32) {
247        self.color.w = a.clamp(0.0, 1.0);
248    }
249    pub fn render_ascii(&self) -> String {
250        format!(
251            "{}: rgba({:.2},{:.2},{:.2},{:.2})",
252            self.name,
253            self.color.x,
254            self.color.y,
255            self.color.z,
256            self.color.w,
257        )
258    }
259    pub fn open_popup(&mut self) { self.popup_open = true; }
260    pub fn close_popup(&mut self) { self.popup_open = false; }
261    pub fn hue(&self) -> f32 { self.hue }
262    pub fn saturation(&self) -> f32 { self.saturation }
263    pub fn brightness(&self) -> f32 { self.brightness }
264}
265
266/// An enumeration drop-down.
267#[derive(Debug, Clone)]
268pub struct EnumField {
269    pub name: String,
270    pub variants: Vec<String>,
271    pub selected: usize,
272}
273
274impl EnumField {
275    pub fn new(name: impl Into<String>, variants: Vec<String>, selected: usize) -> Self {
276        let selected = selected.min(variants.len().saturating_sub(1));
277        Self { name: name.into(), variants, selected }
278    }
279    pub fn selected_name(&self) -> &str {
280        self.variants.get(self.selected).map(|s| s.as_str()).unwrap_or("")
281    }
282    pub fn next(&mut self) {
283        if !self.variants.is_empty() {
284            self.selected = (self.selected + 1) % self.variants.len();
285        }
286    }
287    pub fn prev(&mut self) {
288        if !self.variants.is_empty() {
289            self.selected = if self.selected == 0 { self.variants.len() - 1 } else { self.selected - 1 };
290        }
291    }
292    pub fn render_ascii(&self) -> String {
293        format!("{}: [{}]", self.name, self.selected_name())
294    }
295}
296
297/// A value slider with explicit min/max bounds.
298#[derive(Debug, Clone)]
299pub struct SliderField {
300    pub name: String,
301    pub value: f32,
302    pub min: f32,
303    pub max: f32,
304    pub display_precision: usize,
305}
306
307impl SliderField {
308    pub fn new(name: impl Into<String>, value: f32, min: f32, max: f32) -> Self {
309        let value = value.clamp(min, max);
310        Self { name: name.into(), value, min, max, display_precision: 2 }
311    }
312    pub fn set(&mut self, v: f32) {
313        self.value = v.clamp(self.min, self.max);
314    }
315    /// Normalised position in [0, 1].
316    pub fn normalized(&self) -> f32 {
317        if (self.max - self.min).abs() < f32::EPSILON { 0.0 }
318        else { (self.value - self.min) / (self.max - self.min) }
319    }
320    pub fn set_normalized(&mut self, t: f32) {
321        self.value = self.min + t.clamp(0.0, 1.0) * (self.max - self.min);
322    }
323    pub fn render_ascii(&self, bar_width: usize) -> String {
324        let filled = ((self.normalized() * bar_width as f32) as usize).min(bar_width);
325        let bar: String = std::iter::repeat('#').take(filled)
326            .chain(std::iter::repeat('-').take(bar_width - filled))
327            .collect();
328        format!("{}: [{}] {:.prec$}", self.name, bar, self.value, prec = self.display_precision)
329    }
330}
331
332/// A reference to an asset by path / ID.
333#[derive(Debug, Clone)]
334pub struct AssetRefField {
335    pub name: String,
336    pub asset_type: String,
337    pub asset_path: Option<String>,
338    pub asset_id: Option<u64>,
339}
340
341impl AssetRefField {
342    pub fn new(name: impl Into<String>, asset_type: impl Into<String>) -> Self {
343        Self { name: name.into(), asset_type: asset_type.into(), asset_path: None, asset_id: None }
344    }
345    pub fn set_path(&mut self, path: impl Into<String>) {
346        self.asset_path = Some(path.into());
347    }
348    pub fn clear(&mut self) {
349        self.asset_path = None;
350        self.asset_id = None;
351    }
352    pub fn render_ascii(&self) -> String {
353        let val = self.asset_path.as_deref().unwrap_or("<none>");
354        format!("{} [{}]: {}", self.name, self.asset_type, val)
355    }
356}
357
358/// A script file reference with an embedded source snippet.
359#[derive(Debug, Clone)]
360pub struct ScriptField {
361    pub name: String,
362    pub script_path: Option<String>,
363    pub inline_source: String,
364    pub bound_globals: HashMap<String, String>,
365}
366
367impl ScriptField {
368    pub fn new(name: impl Into<String>) -> Self {
369        Self {
370            name: name.into(),
371            script_path: None,
372            inline_source: String::new(),
373            bound_globals: HashMap::new(),
374        }
375    }
376    pub fn set_global(&mut self, key: impl Into<String>, value: impl Into<String>) {
377        self.bound_globals.insert(key.into(), value.into());
378    }
379    pub fn render_ascii(&self) -> String {
380        let src = self.script_path.as_deref().unwrap_or("<inline>");
381        format!("{}: {}", self.name, src)
382    }
383}
384
385/// A list of homogeneous values.
386#[derive(Debug, Clone)]
387pub struct ListField {
388    pub name: String,
389    pub items: Vec<String>, // serialized as strings for generic display
390    pub selected_index: Option<usize>,
391    pub collapsed: bool,
392}
393
394impl ListField {
395    pub fn new(name: impl Into<String>) -> Self {
396        Self { name: name.into(), items: Vec::new(), selected_index: None, collapsed: false }
397    }
398    pub fn push(&mut self, item: impl Into<String>) {
399        self.items.push(item.into());
400    }
401    pub fn remove_selected(&mut self) {
402        if let Some(i) = self.selected_index {
403            if i < self.items.len() {
404                self.items.remove(i);
405                self.selected_index = if self.items.is_empty() {
406                    None
407                } else {
408                    Some(i.min(self.items.len() - 1))
409                };
410            }
411        }
412    }
413    pub fn render_ascii(&self) -> String {
414        let mut out = format!("{}:\n", self.name);
415        for (i, item) in self.items.iter().enumerate() {
416            let cursor = if self.selected_index == Some(i) { ">" } else { " " };
417            out.push_str(&format!("  {}{}: {}\n", cursor, i, item));
418        }
419        out
420    }
421}
422
423/// A key-value map field.
424#[derive(Debug, Clone)]
425pub struct MapField {
426    pub name: String,
427    pub entries: Vec<(String, String)>,
428    pub collapsed: bool,
429}
430
431impl MapField {
432    pub fn new(name: impl Into<String>) -> Self {
433        Self { name: name.into(), entries: Vec::new(), collapsed: false }
434    }
435    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
436        let k: String = key.into();
437        if let Some(e) = self.entries.iter_mut().find(|(ek, _)| ek == &k) {
438            e.1 = value.into();
439        } else {
440            self.entries.push((k, value.into()));
441        }
442    }
443    pub fn remove(&mut self, key: &str) {
444        self.entries.retain(|(k, _)| k != key);
445    }
446    pub fn get(&self, key: &str) -> Option<&str> {
447        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
448    }
449    pub fn render_ascii(&self) -> String {
450        let mut out = format!("{}:\n", self.name);
451        for (k, v) in &self.entries {
452            out.push_str(&format!("  {} = {}\n", k, v));
453        }
454        out
455    }
456}
457
458// ─────────────────────────────────────────────────────────────────────────────
459// InspectorEntry — tagged union of all field types
460// ─────────────────────────────────────────────────────────────────────────────
461
462#[derive(Debug, Clone)]
463pub enum InspectorEntry {
464    Bool(BoolField),
465    Int(IntField),
466    Float(FloatField),
467    String(StringField),
468    Vec2(Vec2Field),
469    Vec3(Vec3Field),
470    Vec4(Vec4Field),
471    Color(ColorField),
472    Enum(EnumField),
473    Slider(SliderField),
474    AssetRef(AssetRefField),
475    Script(ScriptField),
476    List(ListField),
477    Map(MapField),
478    Separator,
479    Label(std::string::String),
480}
481
482impl InspectorEntry {
483    pub fn name(&self) -> &str {
484        match self {
485            InspectorEntry::Bool(f) => &f.name,
486            InspectorEntry::Int(f) => &f.name,
487            InspectorEntry::Float(f) => &f.name,
488            InspectorEntry::String(f) => &f.name,
489            InspectorEntry::Vec2(f) => &f.name,
490            InspectorEntry::Vec3(f) => &f.name,
491            InspectorEntry::Vec4(f) => &f.name,
492            InspectorEntry::Color(f) => &f.name,
493            InspectorEntry::Enum(f) => &f.name,
494            InspectorEntry::Slider(f) => &f.name,
495            InspectorEntry::AssetRef(f) => &f.name,
496            InspectorEntry::Script(f) => &f.name,
497            InspectorEntry::List(f) => &f.name,
498            InspectorEntry::Map(f) => &f.name,
499            InspectorEntry::Separator => "",
500            InspectorEntry::Label(s) => s,
501        }
502    }
503
504    /// Render as an ASCII line for the console/debug overlay.
505    pub fn render_ascii(&self) -> String {
506        match self {
507            InspectorEntry::Bool(f) => f.render_ascii(),
508            InspectorEntry::Int(f) => f.render_ascii(),
509            InspectorEntry::Float(f) => f.render_ascii(),
510            InspectorEntry::String(f) => f.render_ascii(),
511            InspectorEntry::Vec2(f) => f.render_ascii(),
512            InspectorEntry::Vec3(f) => f.render_ascii(),
513            InspectorEntry::Vec4(f) => f.render_ascii(),
514            InspectorEntry::Color(f) => f.render_ascii(),
515            InspectorEntry::Enum(f) => f.render_ascii(),
516            InspectorEntry::Slider(f) => f.render_ascii(20),
517            InspectorEntry::AssetRef(f) => f.render_ascii(),
518            InspectorEntry::Script(f) => f.render_ascii(),
519            InspectorEntry::List(f) => f.render_ascii(),
520            InspectorEntry::Map(f) => f.render_ascii(),
521            InspectorEntry::Separator => "────────────────────────────────────".into(),
522            InspectorEntry::Label(s) => s.clone(),
523        }
524    }
525
526    pub fn matches_search(&self, query: &str) -> bool {
527        if query.is_empty() { return true; }
528        self.name().to_lowercase().contains(&query.to_lowercase())
529    }
530}
531
532// ─────────────────────────────────────────────────────────────────────────────
533// Inspectable trait
534// ─────────────────────────────────────────────────────────────────────────────
535
536/// Implement this trait to make a type editable in the inspector.
537pub trait Inspectable {
538    /// Returns the property groups for this type.
539    fn inspect(&self) -> Vec<PropertyGroup>;
540    /// Apply an edited entry value back to self (by entry name + serialized value).
541    fn apply_edit(&mut self, entry_name: &str, serialized_value: &str) -> Result<(), String>;
542    /// Human-readable type name.
543    fn type_name(&self) -> &'static str;
544}
545
546// ─────────────────────────────────────────────────────────────────────────────
547// PropertyGroup
548// ─────────────────────────────────────────────────────────────────────────────
549
550/// A named, foldable group of inspector entries.
551#[derive(Debug, Clone)]
552pub struct PropertyGroup {
553    pub name: String,
554    pub entries: Vec<InspectorEntry>,
555    pub collapsed: bool,
556    pub icon: char,
557}
558
559impl PropertyGroup {
560    pub fn new(name: impl Into<String>) -> Self {
561        Self { name: name.into(), entries: Vec::new(), collapsed: false, icon: '▼' }
562    }
563    pub fn with_icon(mut self, icon: char) -> Self {
564        self.icon = icon; self
565    }
566    pub fn add(mut self, entry: InspectorEntry) -> Self {
567        self.entries.push(entry); self
568    }
569    pub fn push(&mut self, entry: InspectorEntry) {
570        self.entries.push(entry);
571    }
572    pub fn toggle_collapsed(&mut self) {
573        self.collapsed = !self.collapsed;
574        self.icon = if self.collapsed { '▶' } else { '▼' };
575    }
576    pub fn visible_entries(&self) -> &[InspectorEntry] {
577        if self.collapsed { &[] } else { &self.entries }
578    }
579    pub fn render_ascii(&self) -> String {
580        let mut out = format!("{} {}\n", self.icon, self.name);
581        if !self.collapsed {
582            for e in &self.entries {
583                out.push_str("  ");
584                out.push_str(&e.render_ascii());
585                out.push('\n');
586            }
587        }
588        out
589    }
590    pub fn filter(&self, query: &str) -> Vec<&InspectorEntry> {
591        self.entries.iter().filter(|e| e.matches_search(query)).collect()
592    }
593}
594
595// ─────────────────────────────────────────────────────────────────────────────
596// InspectorContext
597// ─────────────────────────────────────────────────────────────────────────────
598
599/// Tracks dirty state, validation errors and the clipboard for copy/paste.
600#[derive(Debug, Clone, Default)]
601pub struct InspectorContext {
602    pub dirty_properties: Vec<String>,
603    pub validation_errors: HashMap<String, String>,
604    pub clipboard: Option<ClipboardEntry>,
605    pub undo_stack: Vec<(String, String, String)>, // (entity_id, prop, old_value)
606}
607
608/// A value that has been copied to the inspector clipboard.
609#[derive(Debug, Clone)]
610pub struct ClipboardEntry {
611    pub source_type: String,
612    pub property_name: String,
613    pub serialized_value: String,
614}
615
616impl InspectorContext {
617    pub fn new() -> Self { Self::default() }
618
619    pub fn mark_dirty(&mut self, name: impl Into<String>) {
620        let n = name.into();
621        if !self.dirty_properties.contains(&n) {
622            self.dirty_properties.push(n);
623        }
624    }
625
626    pub fn clear_dirty(&mut self) {
627        self.dirty_properties.clear();
628    }
629
630    pub fn is_dirty(&self, name: &str) -> bool {
631        self.dirty_properties.contains(&name.to_string())
632    }
633
634    pub fn add_validation_error(&mut self, prop: impl Into<String>, msg: impl Into<String>) {
635        self.validation_errors.insert(prop.into(), msg.into());
636    }
637
638    pub fn clear_validation_error(&mut self, prop: &str) {
639        self.validation_errors.remove(prop);
640    }
641
642    pub fn has_errors(&self) -> bool {
643        !self.validation_errors.is_empty()
644    }
645
646    pub fn copy_value(&mut self, entry: &InspectorEntry, source_type: impl Into<String>) {
647        self.clipboard = Some(ClipboardEntry {
648            source_type: source_type.into(),
649            property_name: entry.name().to_string(),
650            serialized_value: entry.render_ascii(),
651        });
652    }
653
654    pub fn paste_value(&self) -> Option<&ClipboardEntry> {
655        self.clipboard.as_ref()
656    }
657}
658
659// ─────────────────────────────────────────────────────────────────────────────
660// SearchBar
661// ─────────────────────────────────────────────────────────────────────────────
662
663/// A text filter for property names.
664#[derive(Debug, Clone, Default)]
665pub struct SearchBar {
666    pub query: String,
667    pub focused: bool,
668    pub case_sensitive: bool,
669}
670
671impl SearchBar {
672    pub fn new() -> Self { Self::default() }
673
674    pub fn push(&mut self, c: char) {
675        self.query.push(c);
676    }
677    pub fn pop(&mut self) {
678        self.query.pop();
679    }
680    pub fn clear(&mut self) {
681        self.query.clear();
682    }
683    pub fn matches(&self, text: &str) -> bool {
684        if self.query.is_empty() { return true; }
685        if self.case_sensitive {
686            text.contains(&self.query)
687        } else {
688            text.to_lowercase().contains(&self.query.to_lowercase())
689        }
690    }
691    pub fn render_ascii(&self) -> String {
692        format!("Search: [{}{}]", self.query, if self.focused { "|" } else { "" })
693    }
694}
695
696// ─────────────────────────────────────────────────────────────────────────────
697// TransformInspector
698// ─────────────────────────────────────────────────────────────────────────────
699
700/// Inspector section for position/rotation/scale.
701#[derive(Debug, Clone)]
702pub struct TransformInspector {
703    pub position: Vec3Field,
704    pub rotation: Vec3Field, // Euler angles in degrees
705    pub scale: Vec3Field,
706    pub snap_position: f32,
707    pub snap_rotation: f32,
708    pub snap_scale: f32,
709    pub snap_enabled: bool,
710    pub local_space: bool,
711}
712
713impl TransformInspector {
714    pub fn new(pos: Vec3, rot: Vec3, scale: Vec3) -> Self {
715        Self {
716            position: Vec3Field::new("Position", pos),
717            rotation: Vec3Field::new("Rotation", rot),
718            scale:    Vec3Field::new("Scale",    scale),
719            snap_position: 0.25,
720            snap_rotation: 15.0,
721            snap_scale:    0.1,
722            snap_enabled: false,
723            local_space: true,
724        }
725    }
726
727    pub fn apply_snap_position(&mut self) {
728        if !self.snap_enabled { return; }
729        let s = self.snap_position;
730        let p = self.position.value;
731        self.position.set(Vec3::new(
732            (p.x / s).round() * s,
733            (p.y / s).round() * s,
734            (p.z / s).round() * s,
735        ));
736    }
737
738    pub fn apply_snap_rotation(&mut self) {
739        if !self.snap_enabled { return; }
740        let s = self.snap_rotation;
741        let r = self.rotation.value;
742        self.rotation.set(Vec3::new(
743            (r.x / s).round() * s,
744            (r.y / s).round() * s,
745            (r.z / s).round() * s,
746        ));
747    }
748
749    pub fn to_property_group(&self) -> PropertyGroup {
750        let mut g = PropertyGroup::new("Transform").with_icon('↔');
751        g.push(InspectorEntry::Vec3(self.position.clone()));
752        g.push(InspectorEntry::Vec3(self.rotation.clone()));
753        g.push(InspectorEntry::Vec3(self.scale.clone()));
754        g.push(InspectorEntry::Bool(BoolField::new("Snap", self.snap_enabled)));
755        g
756    }
757}
758
759// ─────────────────────────────────────────────────────────────────────────────
760// GlyphInspector
761// ─────────────────────────────────────────────────────────────────────────────
762
763/// Inspector section for a single glyph's visual properties.
764#[derive(Debug, Clone)]
765pub struct GlyphInspector {
766    pub character:  StringField,
767    pub color:      ColorField,
768    pub emission:   SliderField,
769    pub glow_color: ColorField,
770    pub glow_radius: FloatField,
771    pub layer:      EnumField,
772    pub blend_mode: EnumField,
773    pub mass:       FloatField,
774    pub charge:     FloatField,
775    pub temperature: FloatField,
776    pub entropy:    SliderField,
777}
778
779impl GlyphInspector {
780    pub fn new() -> Self {
781        Self {
782            character:  StringField::new("Character", "A"),
783            color:      ColorField::new("Color",      Vec4::ONE),
784            emission:   SliderField::new("Emission",  0.0, 0.0, 2.0),
785            glow_color: ColorField::new("Glow Color", Vec4::new(1.0, 0.8, 0.2, 1.0)),
786            glow_radius: FloatField::new("Glow Radius", 1.0).with_range(0.0, 20.0),
787            layer: EnumField::new(
788                "Layer",
789                vec!["Background".into(), "World".into(), "Entity".into(),
790                     "Particle".into(), "UI".into(), "Overlay".into()],
791                2,
792            ),
793            blend_mode: EnumField::new(
794                "Blend Mode",
795                vec!["Normal".into(), "Additive".into(), "Multiply".into(), "Screen".into()],
796                0,
797            ),
798            mass:        FloatField::new("Mass",        1.0).with_range(0.001, 1000.0),
799            charge:      FloatField::new("Charge",      0.0).with_range(-10.0, 10.0),
800            temperature: FloatField::new("Temperature", 0.0).with_range(0.0, 10000.0),
801            entropy:     SliderField::new("Entropy",    0.0, 0.0, 1.0),
802        }
803    }
804
805    pub fn to_property_group(&self) -> PropertyGroup {
806        let mut g = PropertyGroup::new("Glyph").with_icon('✦');
807        g.push(InspectorEntry::String(self.character.clone()));
808        g.push(InspectorEntry::Color(self.color.clone()));
809        g.push(InspectorEntry::Slider(self.emission.clone()));
810        g.push(InspectorEntry::Color(self.glow_color.clone()));
811        g.push(InspectorEntry::Float(self.glow_radius.clone()));
812        g.push(InspectorEntry::Enum(self.layer.clone()));
813        g.push(InspectorEntry::Enum(self.blend_mode.clone()));
814        g.push(InspectorEntry::Separator);
815        g.push(InspectorEntry::Float(self.mass.clone()));
816        g.push(InspectorEntry::Float(self.charge.clone()));
817        g.push(InspectorEntry::Float(self.temperature.clone()));
818        g.push(InspectorEntry::Slider(self.entropy.clone()));
819        g
820    }
821}
822
823impl Default for GlyphInspector {
824    fn default() -> Self { Self::new() }
825}
826
827// ─────────────────────────────────────────────────────────────────────────────
828// ForceFieldInspector
829// ─────────────────────────────────────────────────────────────────────────────
830
831/// Inspector section for force-field parameters.
832#[derive(Debug, Clone)]
833pub struct ForceFieldInspector {
834    pub field_type: EnumField,
835    pub strength:   FloatField,
836    pub radius:     FloatField,
837    pub falloff:    EnumField,
838    pub position:   Vec3Field,
839    pub direction:  Vec3Field,
840    pub enabled:    BoolField,
841    pub frequency:  FloatField,
842    pub amplitude:  FloatField,
843    pub phase:      FloatField,
844}
845
846impl ForceFieldInspector {
847    pub fn new() -> Self {
848        Self {
849            field_type: EnumField::new(
850                "Type",
851                vec!["Gravity".into(), "Repulsion".into(), "Vortex".into(),
852                     "Attractor".into(), "Wind".into(), "Turbulence".into(),
853                     "Drag".into(), "Custom".into()],
854                0,
855            ),
856            strength:  FloatField::new("Strength",  1.0).with_range(-1000.0, 1000.0),
857            radius:    FloatField::new("Radius",     5.0).with_range(0.0, 500.0),
858            falloff:   EnumField::new(
859                "Falloff",
860                vec!["None".into(), "Linear".into(), "InvSq".into(), "Exp".into()],
861                1,
862            ),
863            position:  Vec3Field::new("Position",  Vec3::ZERO),
864            direction: Vec3Field::new("Direction", Vec3::new(0.0, -1.0, 0.0)),
865            enabled:   BoolField::new("Enabled",   true),
866            frequency: FloatField::new("Frequency", 1.0).with_range(0.0, 100.0),
867            amplitude: FloatField::new("Amplitude", 1.0).with_range(0.0, 100.0),
868            phase:     FloatField::new("Phase",     0.0).with_range(0.0, std::f64::consts::TAU),
869        }
870    }
871
872    pub fn to_property_group(&self) -> PropertyGroup {
873        let mut g = PropertyGroup::new("Force Field").with_icon('⊛');
874        g.push(InspectorEntry::Bool(self.enabled.clone()));
875        g.push(InspectorEntry::Enum(self.field_type.clone()));
876        g.push(InspectorEntry::Float(self.strength.clone()));
877        g.push(InspectorEntry::Float(self.radius.clone()));
878        g.push(InspectorEntry::Enum(self.falloff.clone()));
879        g.push(InspectorEntry::Vec3(self.position.clone()));
880        g.push(InspectorEntry::Vec3(self.direction.clone()));
881        g.push(InspectorEntry::Separator);
882        g.push(InspectorEntry::Float(self.frequency.clone()));
883        g.push(InspectorEntry::Float(self.amplitude.clone()));
884        g.push(InspectorEntry::Float(self.phase.clone()));
885        g
886    }
887}
888
889impl Default for ForceFieldInspector {
890    fn default() -> Self { Self::new() }
891}
892
893// ─────────────────────────────────────────────────────────────────────────────
894// ParticleInspector
895// ─────────────────────────────────────────────────────────────────────────────
896
897/// Inspector section for a particle emitter.
898#[derive(Debug, Clone)]
899pub struct ParticleInspector {
900    pub preset:       EnumField,
901    pub origin:       Vec3Field,
902    pub active_count: IntField,
903    pub emit_rate:    FloatField,
904    pub lifetime:     FloatField,
905    pub speed:        FloatField,
906    pub spread_angle: SliderField,
907    pub gravity_scale: FloatField,
908    pub color_start:  ColorField,
909    pub color_end:    ColorField,
910    pub size_start:   FloatField,
911    pub size_end:     FloatField,
912    pub emitting:     BoolField,
913}
914
915impl ParticleInspector {
916    pub fn new() -> Self {
917        Self {
918            preset: EnumField::new(
919                "Preset",
920                vec!["Explosion".into(), "Fire".into(), "Smoke".into(),
921                     "Sparkle".into(), "Rain".into(), "Custom".into()],
922                0,
923            ),
924            origin:       Vec3Field::new("Origin",       Vec3::ZERO),
925            active_count: IntField::new("Active Particles", 0).with_range(0, 100_000),
926            emit_rate:    FloatField::new("Emit Rate",   50.0).with_range(0.0, 10000.0),
927            lifetime:     FloatField::new("Lifetime",     2.0).with_range(0.0, 60.0),
928            speed:        FloatField::new("Speed",        5.0).with_range(0.0, 500.0),
929            spread_angle: SliderField::new("Spread Angle", 45.0, 0.0, 360.0),
930            gravity_scale: FloatField::new("Gravity Scale", 1.0).with_range(-10.0, 10.0),
931            color_start:  ColorField::new("Color Start", Vec4::new(1.0, 0.8, 0.2, 1.0)),
932            color_end:    ColorField::new("Color End",   Vec4::new(1.0, 0.0, 0.0, 0.0)),
933            size_start:   FloatField::new("Size Start",  1.0).with_range(0.01, 10.0),
934            size_end:     FloatField::new("Size End",    0.0).with_range(0.0, 10.0),
935            emitting:     BoolField::new("Emitting",     true),
936        }
937    }
938
939    pub fn to_property_group(&self) -> PropertyGroup {
940        let mut g = PropertyGroup::new("Particle Emitter").with_icon('✦');
941        g.push(InspectorEntry::Bool(self.emitting.clone()));
942        g.push(InspectorEntry::Enum(self.preset.clone()));
943        g.push(InspectorEntry::Vec3(self.origin.clone()));
944        g.push(InspectorEntry::Int(self.active_count.clone()));
945        g.push(InspectorEntry::Float(self.emit_rate.clone()));
946        g.push(InspectorEntry::Float(self.lifetime.clone()));
947        g.push(InspectorEntry::Float(self.speed.clone()));
948        g.push(InspectorEntry::Slider(self.spread_angle.clone()));
949        g.push(InspectorEntry::Float(self.gravity_scale.clone()));
950        g.push(InspectorEntry::Separator);
951        g.push(InspectorEntry::Color(self.color_start.clone()));
952        g.push(InspectorEntry::Color(self.color_end.clone()));
953        g.push(InspectorEntry::Float(self.size_start.clone()));
954        g.push(InspectorEntry::Float(self.size_end.clone()));
955        g
956    }
957}
958
959impl Default for ParticleInspector {
960    fn default() -> Self { Self::new() }
961}
962
963// ─────────────────────────────────────────────────────────────────────────────
964// ScriptInspector
965// ─────────────────────────────────────────────────────────────────────────────
966
967/// Inspector for a script bound to an entity.
968#[derive(Debug, Clone)]
969pub struct ScriptInspector {
970    pub script:    ScriptField,
971    pub globals:   MapField,
972    pub enabled:   BoolField,
973    pub log_output: ListField,
974}
975
976impl ScriptInspector {
977    pub fn new() -> Self {
978        Self {
979            script:     ScriptField::new("Script"),
980            globals:    MapField::new("Globals"),
981            enabled:    BoolField::new("Enabled", true),
982            log_output: ListField::new("Output"),
983        }
984    }
985
986    pub fn to_property_group(&self) -> PropertyGroup {
987        let mut g = PropertyGroup::new("Script").with_icon('⌨');
988        g.push(InspectorEntry::Bool(self.enabled.clone()));
989        g.push(InspectorEntry::Script(self.script.clone()));
990        g.push(InspectorEntry::Map(self.globals.clone()));
991        g.push(InspectorEntry::List(self.log_output.clone()));
992        g
993    }
994
995    pub fn log(&mut self, msg: impl Into<String>) {
996        self.log_output.push(msg);
997    }
998
999    pub fn set_global(&mut self, key: impl Into<String>, val: impl Into<String>) {
1000        self.globals.insert(key, val);
1001        self.script.set_global(
1002            self.globals.entries.last().map(|(k, _)| k.as_str()).unwrap_or(""),
1003            self.globals.entries.last().map(|(_, v)| v.as_str()).unwrap_or(""),
1004        );
1005    }
1006}
1007
1008impl Default for ScriptInspector {
1009    fn default() -> Self { Self::new() }
1010}
1011
1012// ─────────────────────────────────────────────────────────────────────────────
1013// ComponentInspector
1014// ─────────────────────────────────────────────────────────────────────────────
1015
1016/// Shows all components of the selected entity as foldable property groups.
1017#[derive(Debug, Clone)]
1018pub struct ComponentInspector {
1019    pub entity_name: String,
1020    pub entity_id:   u32,
1021    pub transform:   TransformInspector,
1022    pub glyph:       Option<GlyphInspector>,
1023    pub force_field: Option<ForceFieldInspector>,
1024    pub particle:    Option<ParticleInspector>,
1025    pub script:      Option<ScriptInspector>,
1026    pub custom_groups: Vec<PropertyGroup>,
1027}
1028
1029impl ComponentInspector {
1030    pub fn new(name: impl Into<String>, id: u32) -> Self {
1031        Self {
1032            entity_name: name.into(),
1033            entity_id: id,
1034            transform: TransformInspector::new(Vec3::ZERO, Vec3::ZERO, Vec3::ONE),
1035            glyph: None,
1036            force_field: None,
1037            particle: None,
1038            script: None,
1039            custom_groups: Vec::new(),
1040        }
1041    }
1042
1043    pub fn all_groups(&self) -> Vec<PropertyGroup> {
1044        let mut groups = vec![self.transform.to_property_group()];
1045        if let Some(ref g) = self.glyph       { groups.push(g.to_property_group()); }
1046        if let Some(ref f) = self.force_field  { groups.push(f.to_property_group()); }
1047        if let Some(ref p) = self.particle     { groups.push(p.to_property_group()); }
1048        if let Some(ref s) = self.script       { groups.push(s.to_property_group()); }
1049        groups.extend(self.custom_groups.iter().cloned());
1050        groups
1051    }
1052
1053    pub fn add_custom_group(&mut self, group: PropertyGroup) {
1054        self.custom_groups.push(group);
1055    }
1056
1057    pub fn render_ascii(&self, search: &SearchBar) -> String {
1058        let mut out = format!("┌── Entity: {} (id={})\n", self.entity_name, self.entity_id);
1059        for group in self.all_groups() {
1060            let filtered: Vec<_> = group.filter(&search.query);
1061            if !search.query.is_empty() && filtered.is_empty() { continue; }
1062            out.push_str(&group.render_ascii());
1063        }
1064        out.push_str("└──────────────────────────────\n");
1065        out
1066    }
1067}
1068
1069// ─────────────────────────────────────────────────────────────────────────────
1070// Inspector (main panel)
1071// ─────────────────────────────────────────────────────────────────────────────
1072
1073/// The top-level inspector panel.
1074pub struct Inspector {
1075    pub context: InspectorContext,
1076    pub search:  SearchBar,
1077    pub component_inspector: Option<ComponentInspector>,
1078    pub scroll_offset: f32,
1079    pub width:  f32,
1080    pub height: f32,
1081}
1082
1083impl Inspector {
1084    pub fn new(width: f32, height: f32) -> Self {
1085        Self {
1086            context: InspectorContext::new(),
1087            search: SearchBar::new(),
1088            component_inspector: None,
1089            scroll_offset: 0.0,
1090            width,
1091            height,
1092        }
1093    }
1094
1095    /// Load data for a given entity.
1096    pub fn load_entity(&mut self, id: u32, name: impl Into<String>) {
1097        self.component_inspector = Some(ComponentInspector::new(name, id));
1098        self.scroll_offset = 0.0;
1099        self.context.clear_dirty();
1100    }
1101
1102    pub fn clear(&mut self) {
1103        self.component_inspector = None;
1104        self.context.clear_dirty();
1105    }
1106
1107    pub fn scroll(&mut self, delta: f32) {
1108        self.scroll_offset = (self.scroll_offset + delta).max(0.0);
1109    }
1110
1111    /// Render the panel to an ASCII string.
1112    pub fn render_ascii(&self) -> String {
1113        let mut out = String::new();
1114        out.push_str(&self.search.render_ascii());
1115        out.push('\n');
1116        match &self.component_inspector {
1117            Some(ci) => out.push_str(&ci.render_ascii(&self.search)),
1118            None => out.push_str("(no selection)\n"),
1119        }
1120        out
1121    }
1122
1123    /// Copy a named property to the clipboard.
1124    pub fn copy_property(&mut self, entry: &InspectorEntry) {
1125        let type_name = self
1126            .component_inspector
1127            .as_ref()
1128            .map(|ci| ci.entity_name.as_str())
1129            .unwrap_or("unknown");
1130        self.context.copy_value(entry, type_name);
1131    }
1132}
1133
1134// ─────────────────────────────────────────────────────────────────────────────
1135// Colour utility functions
1136// ─────────────────────────────────────────────────────────────────────────────
1137
1138fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
1139    let max = r.max(g).max(b);
1140    let min = r.min(g).min(b);
1141    let delta = max - min;
1142    let h = if delta < f32::EPSILON {
1143        0.0
1144    } else if max == r {
1145        60.0 * (((g - b) / delta) % 6.0)
1146    } else if max == g {
1147        60.0 * ((b - r) / delta + 2.0)
1148    } else {
1149        60.0 * ((r - g) / delta + 4.0)
1150    };
1151    let h = if h < 0.0 { h + 360.0 } else { h };
1152    let s = if max < f32::EPSILON { 0.0 } else { delta / max };
1153    let v = max;
1154    (h, s, v)
1155}
1156
1157fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
1158    if s < f32::EPSILON {
1159        return (v, v, v);
1160    }
1161    let hi = ((h / 60.0) as i32) % 6;
1162    let f = h / 60.0 - (h / 60.0).floor();
1163    let p = v * (1.0 - s);
1164    let q = v * (1.0 - f * s);
1165    let t = v * (1.0 - (1.0 - f) * s);
1166    match hi {
1167        0 => (v, t, p),
1168        1 => (q, v, p),
1169        2 => (p, v, t),
1170        3 => (p, q, v),
1171        4 => (t, p, v),
1172        _ => (v, p, q),
1173    }
1174}
1175
1176// ─────────────────────────────────────────────────────────────────────────────
1177// Tests
1178// ─────────────────────────────────────────────────────────────────────────────
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183
1184    #[test]
1185    fn test_bool_field_toggle() {
1186        let mut f = BoolField::new("visible", true);
1187        assert!(f.value);
1188        f.toggle();
1189        assert!(!f.value);
1190        f.toggle();
1191        assert!(f.value);
1192    }
1193
1194    #[test]
1195    fn test_int_field_clamp() {
1196        let mut f = IntField::new("count", 5).with_range(0, 10);
1197        f.set(15);
1198        assert_eq!(f.value, 10);
1199        f.set(-3);
1200        assert_eq!(f.value, 0);
1201    }
1202
1203    #[test]
1204    fn test_float_field_set() {
1205        let mut f = FloatField::new("speed", 1.0).with_range(0.0, 10.0);
1206        f.set(5.5);
1207        assert!((f.value - 5.5).abs() < 1e-10);
1208        f.set(20.0);
1209        assert!((f.value - 10.0).abs() < 1e-10);
1210    }
1211
1212    #[test]
1213    fn test_slider_normalized() {
1214        let mut s = SliderField::new("vol", 5.0, 0.0, 10.0);
1215        assert!((s.normalized() - 0.5).abs() < 1e-6);
1216        s.set_normalized(1.0);
1217        assert!((s.value - 10.0).abs() < 1e-6);
1218    }
1219
1220    #[test]
1221    fn test_enum_cycle() {
1222        let mut e = EnumField::new("layer", vec!["A".into(), "B".into(), "C".into()], 0);
1223        e.next();
1224        assert_eq!(e.selected_name(), "B");
1225        e.prev();
1226        assert_eq!(e.selected_name(), "A");
1227        e.prev(); // wraps
1228        assert_eq!(e.selected_name(), "C");
1229    }
1230
1231    #[test]
1232    fn test_color_field_hsv_roundtrip() {
1233        let mut c = ColorField::new("col", Vec4::new(1.0, 0.0, 0.0, 1.0));
1234        let (h, s, v) = (c.hue(), c.saturation(), c.brightness());
1235        c.set_hsv(h, s, v);
1236        assert!((c.color.x - 1.0).abs() < 0.01);
1237        assert!((c.color.y).abs() < 0.01);
1238    }
1239
1240    #[test]
1241    fn test_string_field_max_len() {
1242        let mut f = StringField::new("name", "");
1243        f.max_len = 5;
1244        f.set("hello world");
1245        assert_eq!(f.value, "hello");
1246    }
1247
1248    #[test]
1249    fn test_list_field_remove() {
1250        let mut l = ListField::new("items");
1251        l.push("a");
1252        l.push("b");
1253        l.push("c");
1254        l.selected_index = Some(1);
1255        l.remove_selected();
1256        assert_eq!(l.items, vec!["a", "c"]);
1257    }
1258
1259    #[test]
1260    fn test_map_field_insert_update() {
1261        let mut m = MapField::new("props");
1262        m.insert("x", "1");
1263        m.insert("y", "2");
1264        m.insert("x", "99");
1265        assert_eq!(m.get("x"), Some("99"));
1266        assert_eq!(m.entries.len(), 2);
1267    }
1268
1269    #[test]
1270    fn test_search_bar_filter() {
1271        let mut s = SearchBar::new();
1272        s.push('p');
1273        s.push('o');
1274        s.push('s');
1275        assert!(s.matches("position"));
1276        assert!(!s.matches("rotation"));
1277    }
1278
1279    #[test]
1280    fn test_property_group_collapse() {
1281        let mut g = PropertyGroup::new("Transform")
1282            .add(InspectorEntry::Bool(BoolField::new("visible", true)));
1283        assert_eq!(g.visible_entries().len(), 1);
1284        g.toggle_collapsed();
1285        assert_eq!(g.visible_entries().len(), 0);
1286    }
1287
1288    #[test]
1289    fn test_component_inspector_groups() {
1290        let mut ci = ComponentInspector::new("Hero", 1);
1291        ci.glyph = Some(GlyphInspector::new());
1292        let groups = ci.all_groups();
1293        assert!(groups.len() >= 2);
1294    }
1295
1296    #[test]
1297    fn test_inspector_context_dirty() {
1298        let mut ctx = InspectorContext::new();
1299        ctx.mark_dirty("position");
1300        assert!(ctx.is_dirty("position"));
1301        ctx.clear_dirty();
1302        assert!(!ctx.is_dirty("position"));
1303    }
1304
1305    #[test]
1306    fn test_vec3_field_clamping() {
1307        let mut f = Vec3Field::new("pos", Vec3::ZERO);
1308        f.min = Some(-5.0);
1309        f.max = Some(5.0);
1310        f.set(Vec3::new(10.0, -10.0, 3.0));
1311        assert!((f.value.x - 5.0).abs() < 1e-6);
1312        assert!((f.value.y + 5.0).abs() < 1e-6);
1313    }
1314
1315    #[test]
1316    fn test_transform_inspector_snap() {
1317        let mut ti = TransformInspector::new(
1318            Vec3::new(0.3, 0.7, -0.1),
1319            Vec3::ZERO,
1320            Vec3::ONE,
1321        );
1322        ti.snap_enabled = true;
1323        ti.snap_position = 0.25;
1324        ti.apply_snap_position();
1325        let p = ti.position.value;
1326        assert!((p.x - 0.25).abs() < 1e-5);
1327        assert!((p.y - 0.75).abs() < 1e-5);
1328        assert!((p.z).abs() < 1e-5);
1329    }
1330}