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 unfocused rows. Should match the surrounding
195    /// surface (e.g. `theme.popup_bg` in the settings UI) so the list
196    /// doesn't paint a `Color::Reset` strip that falls back to the host
197    /// terminal's default bg — which on a dark-terminal host running the
198    /// light theme renders as a black band over the cream settings panel.
199    pub row_bg: Color,
200    /// Background color for focused entries
201    pub focused_bg: Color,
202    /// Foreground color for focused entries (text on focused background)
203    pub focused_fg: Color,
204    pub add_fg: Color,
205}
206
207impl Default for KeybindingListColors {
208    fn default() -> Self {
209        Self {
210            label_fg: Color::White,
211            key_fg: Color::Yellow,
212            action_fg: Color::Cyan,
213            row_bg: Color::Reset,
214            focused_bg: Color::DarkGray,
215            focused_fg: Color::White,
216            add_fg: Color::Green,
217        }
218    }
219}
220
221/// Layout information for hit testing
222#[derive(Debug, Clone, Default)]
223pub struct KeybindingListLayout {
224    /// (data_index, screen_area) for each visible entry
225    pub entry_rects: Vec<(usize, Rect)>,
226    pub add_rect: Option<Rect>,
227}
228
229impl KeybindingListLayout {
230    /// Find what was clicked at the given coordinates
231    pub fn hit_test(&self, x: u16, y: u16) -> Option<KeybindingListHit> {
232        // Check entry areas
233        for &(data_idx, rect) in self.entry_rects.iter() {
234            if y == rect.y && x >= rect.x && x < rect.x + rect.width {
235                return Some(KeybindingListHit::Entry(data_idx));
236            }
237        }
238
239        // Check add row
240        if let Some(ref add_rect) = self.add_rect {
241            if y == add_rect.y && x >= add_rect.x && x < add_rect.x + add_rect.width {
242                return Some(KeybindingListHit::AddRow);
243            }
244        }
245
246        None
247    }
248}
249
250/// Result of hit testing on a keybinding list
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum KeybindingListHit {
253    /// Clicked on an entry
254    Entry(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![(0, Rect::new(2, 1, 40, 1)), (1, Rect::new(2, 2, 40, 1))],
319            add_rect: Some(Rect::new(2, 3, 40, 1)),
320        };
321
322        assert_eq!(layout.hit_test(10, 1), Some(KeybindingListHit::Entry(0)));
323        assert_eq!(layout.hit_test(10, 2), Some(KeybindingListHit::Entry(1)));
324        assert_eq!(layout.hit_test(10, 3), Some(KeybindingListHit::AddRow));
325        assert_eq!(layout.hit_test(0, 0), None);
326    }
327}