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