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