Skip to main content

fresh/view/controls/keybinding_list/
mod.rs

1//! Keybinding list control for displaying and editing keybindings
2//!
3//! This module provides a complete keybinding list component with:
4//! - State management (`KeybindingListState`)
5//! - Rendering (`render_keybinding_list`)
6//! - Input handling (`KeybindingListState::handle_mouse`, `handle_key`)
7//! - Layout/hit testing (`KeybindingListLayout`)
8
9mod input;
10mod render;
11
12use super::FocusState;
13use ratatui::layout::Rect;
14use ratatui::style::Color;
15use serde_json::Value;
16
17pub use input::KeybindingListEvent;
18pub use render::{format_key_combo, render_keybinding_list};
19
20/// State for an object array control (keybindings, etc.)
21#[derive(Debug, Clone)]
22pub struct KeybindingListState {
23    /// List of items as JSON values
24    pub bindings: Vec<Value>,
25    /// Currently focused item index (None = add-new row)
26    pub focused_index: Option<usize>,
27    /// Label for this control
28    pub label: String,
29    /// Focus state
30    pub focus: FocusState,
31    /// Schema for item type (for creating new entries via dialog)
32    pub item_schema: Option<Box<crate::view::settings::schema::SettingSchema>>,
33    /// JSON pointer to field within item to display as preview (e.g., "/action")
34    pub display_field: Option<String>,
35}
36
37impl KeybindingListState {
38    /// Create a new object array state
39    pub fn new(label: impl Into<String>) -> Self {
40        Self {
41            bindings: Vec::new(),
42            focused_index: None,
43            label: label.into(),
44            focus: FocusState::Normal,
45            item_schema: None,
46            display_field: None,
47        }
48    }
49
50    /// Initialize from a JSON array value
51    pub fn with_bindings(mut self, value: &Value) -> Self {
52        if let Some(arr) = value.as_array() {
53            self.bindings = arr.clone();
54        }
55        self
56    }
57
58    /// Set the focus state
59    pub fn with_focus(mut self, focus: FocusState) -> Self {
60        self.focus = focus;
61        self
62    }
63
64    /// Set the item schema for creating new entries
65    pub fn with_item_schema(
66        mut self,
67        schema: crate::view::settings::schema::SettingSchema,
68    ) -> Self {
69        self.item_schema = Some(Box::new(schema));
70        self
71    }
72
73    /// Set the display field for previewing items
74    pub fn with_display_field(mut self, field: String) -> Self {
75        self.display_field = Some(field);
76        self
77    }
78
79    /// Check if the control is enabled
80    pub fn is_enabled(&self) -> bool {
81        self.focus != FocusState::Disabled
82    }
83
84    /// Convert to JSON value
85    pub fn to_value(&self) -> Value {
86        Value::Array(self.bindings.clone())
87    }
88
89    /// Get the focused binding
90    pub fn focused_binding(&self) -> Option<&Value> {
91        self.focused_index.and_then(|idx| self.bindings.get(idx))
92    }
93
94    /// Focus next entry
95    pub fn focus_next(&mut self) {
96        match self.focused_index {
97            None => {
98                // From add-new to first entry (if any)
99                if !self.bindings.is_empty() {
100                    self.focused_index = Some(0);
101                }
102            }
103            Some(idx) if idx + 1 < self.bindings.len() => {
104                self.focused_index = Some(idx + 1);
105            }
106            Some(_) => {
107                // Last entry, go to add-new
108                self.focused_index = None;
109            }
110        }
111    }
112
113    /// Focus previous entry
114    pub fn focus_prev(&mut self) {
115        match self.focused_index {
116            None => {
117                // From add-new to last entry (if any)
118                if !self.bindings.is_empty() {
119                    self.focused_index = Some(self.bindings.len() - 1);
120                }
121            }
122            Some(0) => {
123                // First entry stays at first
124            }
125            Some(idx) => {
126                self.focused_index = Some(idx - 1);
127            }
128        }
129    }
130
131    /// Remove the focused binding
132    pub fn remove_focused(&mut self) {
133        if let Some(idx) = self.focused_index {
134            if idx < self.bindings.len() {
135                self.bindings.remove(idx);
136                // Adjust focus
137                if self.bindings.is_empty() {
138                    self.focused_index = None;
139                } else if idx >= self.bindings.len() {
140                    self.focused_index = Some(self.bindings.len() - 1);
141                }
142            }
143        }
144    }
145
146    /// Remove binding at index
147    pub fn remove_binding(&mut self, index: usize) {
148        if index < self.bindings.len() {
149            self.bindings.remove(index);
150            // Adjust focus
151            if let Some(focused) = self.focused_index {
152                if focused >= self.bindings.len() {
153                    self.focused_index = if self.bindings.is_empty() {
154                        None
155                    } else {
156                        Some(self.bindings.len() - 1)
157                    };
158                }
159            }
160        }
161    }
162
163    /// Add a new binding
164    pub fn add_binding(&mut self, binding: Value) {
165        self.bindings.push(binding);
166    }
167
168    /// Update the binding at index
169    pub fn update_binding(&mut self, index: usize, binding: Value) {
170        if index < self.bindings.len() {
171            self.bindings[index] = binding;
172        }
173    }
174
175    /// Focus on a specific entry
176    pub fn focus_entry(&mut self, index: usize) {
177        if index < self.bindings.len() {
178            self.focused_index = Some(index);
179        }
180    }
181
182    /// Focus on the add-new row
183    pub fn focus_add_row(&mut self) {
184        self.focused_index = None;
185    }
186}
187
188/// Colors for keybinding list rendering
189#[derive(Debug, Clone, Copy)]
190pub struct KeybindingListColors {
191    pub label_fg: Color,
192    pub key_fg: Color,
193    pub action_fg: Color,
194    /// Background color for focused entries
195    pub focused_bg: Color,
196    /// Foreground color for focused entries (text on focused background)
197    pub focused_fg: Color,
198    pub delete_fg: Color,
199    pub add_fg: Color,
200}
201
202impl Default for KeybindingListColors {
203    fn default() -> Self {
204        Self {
205            label_fg: Color::White,
206            key_fg: Color::Yellow,
207            action_fg: Color::Cyan,
208            focused_bg: Color::DarkGray,
209            focused_fg: Color::White,
210            delete_fg: Color::Red,
211            add_fg: Color::Green,
212        }
213    }
214}
215
216/// Layout information for hit testing
217#[derive(Debug, Clone, Default)]
218pub struct KeybindingListLayout {
219    pub entry_rects: Vec<Rect>,
220    pub delete_rects: Vec<Rect>,
221    pub add_rect: Option<Rect>,
222}
223
224impl KeybindingListLayout {
225    /// Find what was clicked at the given coordinates
226    pub fn hit_test(&self, x: u16, y: u16) -> Option<KeybindingListHit> {
227        // Check delete buttons first (they overlap entry areas)
228        for (idx, rect) in self.delete_rects.iter().enumerate() {
229            if y == rect.y && x >= rect.x && x < rect.x + rect.width {
230                return Some(KeybindingListHit::DeleteButton(idx));
231            }
232        }
233
234        // Check entry areas
235        for (idx, rect) in self.entry_rects.iter().enumerate() {
236            if y == rect.y && x >= rect.x && x < rect.x + rect.width {
237                return Some(KeybindingListHit::Entry(idx));
238            }
239        }
240
241        // Check add row
242        if let Some(ref add_rect) = self.add_rect {
243            if y == add_rect.y && x >= add_rect.x && x < add_rect.x + add_rect.width {
244                return Some(KeybindingListHit::AddRow);
245            }
246        }
247
248        None
249    }
250}
251
252/// Result of hit testing on a keybinding list
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum KeybindingListHit {
255    /// Clicked on an entry
256    Entry(usize),
257    /// Clicked on delete button for entry
258    DeleteButton(usize),
259    /// Clicked on add row
260    AddRow,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_keybinding_list_state_new() {
269        let state = KeybindingListState::new("Keybindings");
270        assert_eq!(state.label, "Keybindings");
271        assert!(state.bindings.is_empty());
272        assert!(state.focused_index.is_none());
273    }
274
275    #[test]
276    fn test_keybinding_list_navigation() {
277        let mut state = KeybindingListState::new("Test");
278        state.add_binding(serde_json::json!({"key": "a", "action": "test1"}));
279        state.add_binding(serde_json::json!({"key": "b", "action": "test2"}));
280
281        // Start at add-new (None)
282        assert!(state.focused_index.is_none());
283
284        // focus_next goes to first entry
285        state.focus_next();
286        assert_eq!(state.focused_index, Some(0));
287
288        state.focus_next();
289        assert_eq!(state.focused_index, Some(1));
290
291        // At last entry, focus_next wraps to add-new
292        state.focus_next();
293        assert!(state.focused_index.is_none());
294
295        // focus_prev from add-new goes to last entry
296        state.focus_prev();
297        assert_eq!(state.focused_index, Some(1));
298
299        state.focus_prev();
300        assert_eq!(state.focused_index, Some(0));
301
302        // At first entry, focus_prev stays
303        state.focus_prev();
304        assert_eq!(state.focused_index, Some(0));
305    }
306
307    #[test]
308    fn test_keybinding_list_remove() {
309        let mut state = KeybindingListState::new("Test");
310        state.add_binding(serde_json::json!({"key": "a", "action": "test1"}));
311        state.add_binding(serde_json::json!({"key": "b", "action": "test2"}));
312        state.focus_entry(0);
313
314        state.remove_focused();
315        assert_eq!(state.bindings.len(), 1);
316        assert_eq!(state.focused_index, Some(0));
317    }
318
319    #[test]
320    fn test_keybinding_list_hit_test() {
321        let layout = KeybindingListLayout {
322            entry_rects: vec![Rect::new(2, 1, 40, 1), Rect::new(2, 2, 40, 1)],
323            delete_rects: vec![Rect::new(38, 1, 3, 1), Rect::new(38, 2, 3, 1)],
324            add_rect: Some(Rect::new(2, 3, 40, 1)),
325        };
326
327        assert_eq!(
328            layout.hit_test(38, 1),
329            Some(KeybindingListHit::DeleteButton(0))
330        );
331        assert_eq!(layout.hit_test(10, 1), Some(KeybindingListHit::Entry(0)));
332        assert_eq!(layout.hit_test(10, 2), Some(KeybindingListHit::Entry(1)));
333        assert_eq!(layout.hit_test(10, 3), Some(KeybindingListHit::AddRow));
334        assert_eq!(layout.hit_test(0, 0), None);
335    }
336}