Skip to main content

fresh/view/controls/map_input/
mod.rs

1//! Map/Dictionary control for editing key-value pairs
2//!
3//! Renders as an expandable list of entries:
4//! ```text
5//! Languages:
6//!   > rust
7//!   > python
8//!   ▼ javascript (expanded)
9//!       tab_size: 2
10//!       auto_indent: [x]
11//!   [+ Add entry...]
12//! ```
13//!
14//! This module provides a complete map control with:
15//! - State management (`MapState`)
16//! - Rendering (`render_map`)
17//! - Input handling (`MapState::handle_mouse`, `handle_key`)
18//! - Layout/hit testing (`MapLayout`)
19
20mod input;
21mod render;
22
23use ratatui::layout::Rect;
24use ratatui::style::Color;
25
26pub use input::MapEvent;
27pub use render::render_map;
28
29use super::FocusState;
30
31/// State for a map/dictionary control
32#[derive(Debug, Clone)]
33pub struct MapState {
34    /// Map entries as (key, value) pairs where value is JSON
35    pub entries: Vec<(String, serde_json::Value)>,
36    /// Currently focused entry index (None = add-new field)
37    pub focused_entry: Option<usize>,
38    /// Text in the "add new" key field
39    pub new_key_text: String,
40    /// Cursor position in the new key field
41    pub cursor: usize,
42    /// Label for this map
43    pub label: String,
44    /// Focus state
45    pub focus: FocusState,
46    /// Expanded entry indices (for viewing/editing nested values)
47    pub expanded: Vec<usize>,
48    /// Schema for value type (for creating new entries)
49    pub value_schema: Option<Box<crate::view::settings::schema::SettingSchema>>,
50    /// JSON pointer to field within value to display as preview (e.g., "/command")
51    pub display_field: Option<String>,
52    /// Whether to disallow adding new entries (entries are auto-managed)
53    pub no_add: bool,
54}
55
56impl MapState {
57    /// Create a new map state
58    pub fn new(label: impl Into<String>) -> Self {
59        Self {
60            entries: Vec::new(),
61            focused_entry: None,
62            new_key_text: String::new(),
63            cursor: 0,
64            label: label.into(),
65            focus: FocusState::Normal,
66            expanded: Vec::new(),
67            value_schema: None,
68            display_field: None,
69            no_add: false,
70        }
71    }
72
73    /// Set the display field (JSON pointer to field within value to show as preview)
74    pub fn with_display_field(mut self, field: String) -> Self {
75        self.display_field = Some(field);
76        self
77    }
78
79    /// Set whether adding new entries is disallowed
80    pub fn with_no_add(mut self, no_add: bool) -> Self {
81        self.no_add = no_add;
82        self
83    }
84
85    /// Get the display value for an entry (either from display_field or fallback to field count)
86    pub fn get_display_value(&self, value: &serde_json::Value) -> String {
87        if let Some(ref field) = self.display_field {
88            // For array values, apply display_field to the first element
89            let target = if let serde_json::Value::Array(arr) = value {
90                arr.first()
91            } else {
92                Some(value)
93            };
94            if let Some(target) = target {
95                if let Some(v) = target.pointer(field) {
96                    return match v {
97                        serde_json::Value::String(s) => s.clone(),
98                        serde_json::Value::Bool(b) => b.to_string(),
99                        serde_json::Value::Number(n) => n.to_string(),
100                        serde_json::Value::Null => "null".to_string(),
101                        serde_json::Value::Array(arr) => format!("[{} items]", arr.len()),
102                        serde_json::Value::Object(obj) => format!("{{{} fields}}", obj.len()),
103                    };
104                }
105            }
106        }
107        // Fallback to showing field count with correct pluralization
108        match value {
109            serde_json::Value::Object(obj) => {
110                let n = obj.len();
111                if n == 1 {
112                    "1 field".to_string()
113                } else {
114                    format!("{n} fields")
115                }
116            }
117            serde_json::Value::Array(arr) => {
118                let n = arr.len();
119                if n == 1 {
120                    "1 item".to_string()
121                } else {
122                    format!("{n} items")
123                }
124            }
125            other => other.to_string(),
126        }
127    }
128
129    /// Set the entries from JSON value
130    pub fn with_entries(mut self, value: &serde_json::Value) -> Self {
131        if let Some(obj) = value.as_object() {
132            self.entries = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
133            // Sort by key for consistent ordering
134            self.entries.sort_by(|a, b| a.0.cmp(&b.0));
135            // Default to first entry if any exist
136            if !self.entries.is_empty() {
137                self.focused_entry = Some(0);
138            }
139        }
140        self
141    }
142
143    /// Set the value schema for creating new entries
144    pub fn with_value_schema(
145        mut self,
146        schema: crate::view::settings::schema::SettingSchema,
147    ) -> Self {
148        self.value_schema = Some(Box::new(schema));
149        self
150    }
151
152    /// Set the focus state
153    pub fn with_focus(mut self, focus: FocusState) -> Self {
154        self.focus = focus;
155        self
156    }
157
158    /// Check if the control is enabled
159    pub fn is_enabled(&self) -> bool {
160        self.focus != FocusState::Disabled
161    }
162
163    /// Add a new entry with the given key and default value
164    pub fn add_entry(&mut self, key: String, value: serde_json::Value) {
165        if self.focus == FocusState::Disabled || key.is_empty() {
166            return;
167        }
168        // Check for duplicate key
169        if self.entries.iter().any(|(k, _)| k == &key) {
170            return;
171        }
172        self.entries.push((key, value));
173        self.entries.sort_by(|a, b| a.0.cmp(&b.0));
174    }
175
176    /// Add entry from the new_key_text field with default value
177    pub fn add_entry_from_input(&mut self) {
178        if self.new_key_text.is_empty() {
179            return;
180        }
181        let key = std::mem::take(&mut self.new_key_text);
182        self.cursor = 0;
183        // Use an empty object as default value
184        self.add_entry(key, serde_json::json!({}));
185    }
186
187    /// Remove an entry by index
188    pub fn remove_entry(&mut self, index: usize) {
189        if self.focus == FocusState::Disabled || index >= self.entries.len() {
190            return;
191        }
192        self.entries.remove(index);
193        // Adjust focused_entry if needed
194        if let Some(focused) = self.focused_entry {
195            if focused >= self.entries.len() {
196                self.focused_entry = if self.entries.is_empty() {
197                    None
198                } else {
199                    Some(self.entries.len() - 1)
200                };
201            }
202        }
203        // Remove from expanded list
204        self.expanded.retain(|&idx| idx != index);
205        // Adjust expanded indices
206        self.expanded = self
207            .expanded
208            .iter()
209            .map(|&idx| if idx > index { idx - 1 } else { idx })
210            .collect();
211    }
212
213    /// Focus on an entry
214    pub fn focus_entry(&mut self, index: usize) {
215        if index < self.entries.len() {
216            self.focused_entry = Some(index);
217        }
218    }
219
220    /// Focus on the new entry field
221    pub fn focus_new_entry(&mut self) {
222        self.focused_entry = None;
223        self.cursor = self.new_key_text.len();
224    }
225
226    /// Toggle expansion of an entry
227    pub fn toggle_expand(&mut self, index: usize) {
228        if index >= self.entries.len() {
229            return;
230        }
231        if let Some(pos) = self.expanded.iter().position(|&i| i == index) {
232            self.expanded.remove(pos);
233        } else {
234            self.expanded.push(index);
235        }
236    }
237
238    /// Check if an entry is expanded
239    pub fn is_expanded(&self, index: usize) -> bool {
240        self.expanded.contains(&index)
241    }
242
243    /// Insert a character in the new key field
244    pub fn insert(&mut self, c: char) {
245        if self.focus == FocusState::Disabled || self.focused_entry.is_some() {
246            return;
247        }
248        self.new_key_text.insert(self.cursor, c);
249        self.cursor += 1;
250    }
251
252    /// Backspace in the new key field
253    pub fn backspace(&mut self) {
254        if self.focus == FocusState::Disabled || self.focused_entry.is_some() || self.cursor == 0 {
255            return;
256        }
257        self.cursor -= 1;
258        self.new_key_text.remove(self.cursor);
259    }
260
261    /// Move cursor left
262    pub fn move_left(&mut self) {
263        if self.cursor > 0 {
264            self.cursor -= 1;
265        }
266    }
267
268    /// Move cursor right
269    pub fn move_right(&mut self) {
270        if self.cursor < self.new_key_text.len() {
271            self.cursor += 1;
272        }
273    }
274
275    /// Move focus to previous entry. Returns true if handled, false if should move to prev item.
276    pub fn focus_prev(&mut self) -> bool {
277        match self.focused_entry {
278            Some(0) => false, // At first entry, move to previous item
279            Some(idx) => {
280                self.focused_entry = Some(idx - 1);
281                true
282            }
283            None if !self.entries.is_empty() => {
284                self.focused_entry = Some(self.entries.len() - 1);
285                true
286            }
287            None => false, // Empty map or at add-new with no entries, move to previous item
288        }
289    }
290
291    /// Move focus to next entry. Returns true if handled, false if should move to next item.
292    pub fn focus_next(&mut self) -> bool {
293        match self.focused_entry {
294            Some(idx) if idx + 1 < self.entries.len() => {
295                self.focused_entry = Some(idx + 1);
296                true
297            }
298            Some(_) if self.no_add => {
299                // At last entry but no_add is true, move to next item
300                false
301            }
302            Some(_) => {
303                // At last entry, go to add-new
304                self.focused_entry = None;
305                self.cursor = self.new_key_text.len();
306                true
307            }
308            None => false, // At add-new, move to next item
309        }
310    }
311
312    /// Initialize focus when entering the control.
313    /// `from_above`: true = start at first entry, false = start at add-new field (or last entry if no_add)
314    pub fn init_focus(&mut self, from_above: bool) {
315        if from_above && !self.entries.is_empty() {
316            self.focused_entry = Some(0);
317        } else if !from_above && !self.entries.is_empty() && self.no_add {
318            // Coming from below with no_add, go to last entry
319            self.focused_entry = Some(self.entries.len() - 1);
320        } else if !self.no_add {
321            // Can go to add-new field
322            self.focused_entry = None;
323            self.cursor = self.new_key_text.len();
324        } else if !self.entries.is_empty() {
325            // no_add and coming from above, start at first entry
326            self.focused_entry = Some(0);
327        } else {
328            // Empty and no_add, shouldn't happen but handle gracefully
329            self.focused_entry = None;
330        }
331    }
332
333    /// Get the number of entries
334    pub fn len(&self) -> usize {
335        self.entries.len()
336    }
337
338    /// Check if the map is empty
339    pub fn is_empty(&self) -> bool {
340        self.entries.is_empty()
341    }
342
343    /// Convert entries to JSON value
344    pub fn to_value(&self) -> serde_json::Value {
345        let map: serde_json::Map<String, serde_json::Value> = self
346            .entries
347            .iter()
348            .map(|(k, v)| (k.clone(), v.clone()))
349            .collect();
350        serde_json::Value::Object(map)
351    }
352}
353
354/// Colors for the map control
355#[derive(Debug, Clone, Copy)]
356pub struct MapColors {
357    pub label: Color,
358    pub key: Color,
359    pub value_preview: Color,
360    pub border: Color,
361    pub remove_button: Color,
362    pub add_button: Color,
363    /// Background color for focused entries
364    pub focused: Color,
365    /// Foreground color for focused entries (text on focused background)
366    pub focused_fg: Color,
367    pub cursor: Color,
368    pub disabled: Color,
369    pub expand_arrow: Color,
370}
371
372impl Default for MapColors {
373    fn default() -> Self {
374        Self {
375            label: Color::White,
376            key: Color::Cyan,
377            value_preview: Color::Gray,
378            border: Color::Gray,
379            remove_button: Color::Red,
380            add_button: Color::Green,
381            focused: Color::Yellow,
382            focused_fg: Color::Black,
383            cursor: Color::Yellow,
384            disabled: Color::DarkGray,
385            expand_arrow: Color::White,
386        }
387    }
388}
389
390impl MapColors {
391    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
392        Self {
393            label: theme.editor_fg,
394            // Use help_key_fg (cyan) for keys - visible on popup/editor background
395            key: theme.help_key_fg,
396            value_preview: theme.line_number_fg,
397            border: theme.line_number_fg,
398            remove_button: theme.diagnostic_error_fg,
399            add_button: theme.diagnostic_info_fg,
400            // Use settings colors for focused items in settings UI
401            focused: theme.settings_selected_bg,
402            focused_fg: theme.settings_selected_fg,
403            cursor: theme.cursor,
404            disabled: theme.line_number_fg,
405            expand_arrow: theme.editor_fg,
406        }
407    }
408}
409
410/// Layout information for hit testing
411#[derive(Debug, Clone, Default)]
412pub struct MapLayout {
413    pub full_area: Rect,
414    pub entry_areas: Vec<MapEntryLayout>,
415    pub add_row_area: Option<Rect>,
416}
417
418/// Layout for an entry row
419#[derive(Debug, Clone)]
420pub struct MapEntryLayout {
421    pub index: usize,
422    pub row_area: Rect,
423    pub expand_area: Rect,
424    pub key_area: Rect,
425    pub remove_area: Rect,
426}
427
428impl MapLayout {
429    /// Find what was clicked at the given coordinates
430    pub fn hit_test(&self, x: u16, y: u16) -> Option<MapHit> {
431        // Check entry rows
432        for entry in &self.entry_areas {
433            if y == entry.row_area.y {
434                if x >= entry.remove_area.x && x < entry.remove_area.x + entry.remove_area.width {
435                    return Some(MapHit::RemoveButton(entry.index));
436                }
437                if x >= entry.expand_area.x && x < entry.expand_area.x + entry.expand_area.width {
438                    return Some(MapHit::ExpandArrow(entry.index));
439                }
440                if x >= entry.key_area.x && x < entry.key_area.x + entry.key_area.width {
441                    return Some(MapHit::EntryKey(entry.index));
442                }
443            }
444        }
445
446        // Check add row - clicking anywhere on the row focuses the input
447        if let Some(ref add_row) = self.add_row_area {
448            if y == add_row.y && x >= add_row.x && x < add_row.x + add_row.width {
449                return Some(MapHit::AddRow);
450            }
451        }
452
453        None
454    }
455}
456
457/// Result of hit testing on a map control
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum MapHit {
460    /// Clicked on expand/collapse arrow
461    ExpandArrow(usize),
462    /// Clicked on entry key
463    EntryKey(usize),
464    /// Clicked on remove button for entry
465    RemoveButton(usize),
466    /// Clicked on add row (input field or button area)
467    AddRow,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_map_state_new() {
476        let state = MapState::new("Test");
477        assert_eq!(state.label, "Test");
478        assert!(state.entries.is_empty());
479        assert!(state.focused_entry.is_none());
480    }
481
482    #[test]
483    fn test_map_state_add_entry() {
484        let mut state = MapState::new("Test");
485        state.add_entry("key1".to_string(), serde_json::json!({"foo": "bar"}));
486        assert_eq!(state.entries.len(), 1);
487        assert_eq!(state.entries[0].0, "key1");
488    }
489
490    #[test]
491    fn test_map_state_remove_entry() {
492        let mut state = MapState::new("Test");
493        state.add_entry("a".to_string(), serde_json::json!({}));
494        state.add_entry("b".to_string(), serde_json::json!({}));
495        state.remove_entry(0);
496        assert_eq!(state.entries.len(), 1);
497        assert_eq!(state.entries[0].0, "b");
498    }
499
500    #[test]
501    fn test_map_state_navigation() {
502        let mut state = MapState::new("Test").with_focus(FocusState::Focused);
503        state.add_entry("a".to_string(), serde_json::json!({}));
504        state.add_entry("b".to_string(), serde_json::json!({}));
505
506        // Start at add-new
507        assert!(state.focused_entry.is_none());
508
509        // Go to last entry
510        state.focus_prev();
511        assert_eq!(state.focused_entry, Some(1));
512
513        // Go to first entry
514        state.focus_prev();
515        assert_eq!(state.focused_entry, Some(0));
516
517        // Go forward
518        state.focus_next();
519        assert_eq!(state.focused_entry, Some(1));
520
521        // Go to add-new
522        state.focus_next();
523        assert!(state.focused_entry.is_none());
524    }
525
526    #[test]
527    fn test_map_state_expand() {
528        let mut state = MapState::new("Test");
529        state.add_entry("key1".to_string(), serde_json::json!({}));
530
531        assert!(!state.is_expanded(0));
532        state.toggle_expand(0);
533        assert!(state.is_expanded(0));
534        state.toggle_expand(0);
535        assert!(!state.is_expanded(0));
536    }
537
538    #[test]
539    fn test_map_hit_test() {
540        let layout = MapLayout {
541            full_area: Rect::new(0, 0, 50, 5),
542            entry_areas: vec![MapEntryLayout {
543                index: 0,
544                row_area: Rect::new(0, 1, 50, 1),
545                expand_area: Rect::new(2, 1, 1, 1),
546                key_area: Rect::new(4, 1, 10, 1),
547                remove_area: Rect::new(40, 1, 3, 1),
548            }],
549            add_row_area: Some(Rect::new(0, 2, 50, 1)),
550        };
551
552        assert_eq!(layout.hit_test(2, 1), Some(MapHit::ExpandArrow(0)));
553        assert_eq!(layout.hit_test(5, 1), Some(MapHit::EntryKey(0)));
554        assert_eq!(layout.hit_test(40, 1), Some(MapHit::RemoveButton(0)));
555        assert_eq!(layout.hit_test(5, 2), Some(MapHit::AddRow));
556        assert_eq!(layout.hit_test(13, 2), Some(MapHit::AddRow));
557        assert_eq!(layout.hit_test(0, 0), None);
558    }
559}