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