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    pub focused_bg: Color,
195    pub delete_fg: Color,
196    pub add_fg: Color,
197}
198
199impl Default for KeybindingListColors {
200    fn default() -> Self {
201        Self {
202            label_fg: Color::White,
203            key_fg: Color::Yellow,
204            action_fg: Color::Cyan,
205            focused_bg: Color::DarkGray,
206            delete_fg: Color::Red,
207            add_fg: Color::Green,
208        }
209    }
210}
211
212/// Layout information for hit testing
213#[derive(Debug, Clone, Default)]
214pub struct KeybindingListLayout {
215    pub entry_rects: Vec<Rect>,
216    pub delete_rects: Vec<Rect>,
217    pub add_rect: Option<Rect>,
218}
219
220impl KeybindingListLayout {
221    /// Find what was clicked at the given coordinates
222    pub fn hit_test(&self, x: u16, y: u16) -> Option<KeybindingListHit> {
223        // Check delete buttons first (they overlap entry areas)
224        for (idx, rect) in self.delete_rects.iter().enumerate() {
225            if y == rect.y && x >= rect.x && x < rect.x + rect.width {
226                return Some(KeybindingListHit::DeleteButton(idx));
227            }
228        }
229
230        // Check entry areas
231        for (idx, rect) in self.entry_rects.iter().enumerate() {
232            if y == rect.y && x >= rect.x && x < rect.x + rect.width {
233                return Some(KeybindingListHit::Entry(idx));
234            }
235        }
236
237        // Check add row
238        if let Some(ref add_rect) = self.add_rect {
239            if y == add_rect.y && x >= add_rect.x && x < add_rect.x + add_rect.width {
240                return Some(KeybindingListHit::AddRow);
241            }
242        }
243
244        None
245    }
246}
247
248/// Result of hit testing on a keybinding list
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum KeybindingListHit {
251    /// Clicked on an entry
252    Entry(usize),
253    /// Clicked on delete button for entry
254    DeleteButton(usize),
255    /// Clicked on add row
256    AddRow,
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_keybinding_list_state_new() {
265        let state = KeybindingListState::new("Keybindings");
266        assert_eq!(state.label, "Keybindings");
267        assert!(state.bindings.is_empty());
268        assert!(state.focused_index.is_none());
269    }
270
271    #[test]
272    fn test_keybinding_list_navigation() {
273        let mut state = KeybindingListState::new("Test");
274        state.add_binding(serde_json::json!({"key": "a", "action": "test1"}));
275        state.add_binding(serde_json::json!({"key": "b", "action": "test2"}));
276
277        // Start at add-new (None)
278        assert!(state.focused_index.is_none());
279
280        // focus_next goes to first entry
281        state.focus_next();
282        assert_eq!(state.focused_index, Some(0));
283
284        state.focus_next();
285        assert_eq!(state.focused_index, Some(1));
286
287        // At last entry, focus_next wraps to add-new
288        state.focus_next();
289        assert!(state.focused_index.is_none());
290
291        // focus_prev from add-new goes to last entry
292        state.focus_prev();
293        assert_eq!(state.focused_index, Some(1));
294
295        state.focus_prev();
296        assert_eq!(state.focused_index, Some(0));
297
298        // At first entry, focus_prev stays
299        state.focus_prev();
300        assert_eq!(state.focused_index, Some(0));
301    }
302
303    #[test]
304    fn test_keybinding_list_remove() {
305        let mut state = KeybindingListState::new("Test");
306        state.add_binding(serde_json::json!({"key": "a", "action": "test1"}));
307        state.add_binding(serde_json::json!({"key": "b", "action": "test2"}));
308        state.focus_entry(0);
309
310        state.remove_focused();
311        assert_eq!(state.bindings.len(), 1);
312        assert_eq!(state.focused_index, Some(0));
313    }
314
315    #[test]
316    fn test_keybinding_list_hit_test() {
317        let layout = KeybindingListLayout {
318            entry_rects: vec![Rect::new(2, 1, 40, 1), Rect::new(2, 2, 40, 1)],
319            delete_rects: vec![Rect::new(38, 1, 3, 1), Rect::new(38, 2, 3, 1)],
320            add_rect: Some(Rect::new(2, 3, 40, 1)),
321        };
322
323        assert_eq!(
324            layout.hit_test(38, 1),
325            Some(KeybindingListHit::DeleteButton(0))
326        );
327        assert_eq!(layout.hit_test(10, 1), Some(KeybindingListHit::Entry(0)));
328        assert_eq!(layout.hit_test(10, 2), Some(KeybindingListHit::Entry(1)));
329        assert_eq!(layout.hit_test(10, 3), Some(KeybindingListHit::AddRow));
330        assert_eq!(layout.hit_test(0, 0), None);
331    }
332}