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