fresh/view/settings/
layout.rs

1//! Settings layout for hit testing
2//!
3//! Tracks the layout of rendered settings UI elements for mouse interaction.
4
5use super::render::ControlLayoutInfo;
6use ratatui::layout::Rect;
7
8/// Layout information for the entire settings UI
9#[derive(Debug, Clone, Default)]
10pub struct SettingsLayout {
11    /// The modal area
12    pub modal_area: Rect,
13    /// Category list items (index, area)
14    pub categories: Vec<(usize, Rect)>,
15    /// Setting items (index, path, area, control_layout)
16    pub items: Vec<ItemLayout>,
17    /// Search result items (page_index, item_index, area)
18    pub search_results: Vec<SearchResultLayout>,
19    /// Layer button area
20    pub layer_button: Option<Rect>,
21    /// Edit config file button area
22    pub edit_button: Option<Rect>,
23    /// Save button area
24    pub save_button: Option<Rect>,
25    /// Cancel button area
26    pub cancel_button: Option<Rect>,
27    /// Reset button area
28    pub reset_button: Option<Rect>,
29    /// Settings panel area (for scroll hit testing)
30    pub settings_panel_area: Option<Rect>,
31    /// Scrollbar area (for drag detection)
32    pub scrollbar_area: Option<Rect>,
33}
34
35/// Layout info for a search result
36#[derive(Debug, Clone)]
37pub struct SearchResultLayout {
38    /// Page index (category)
39    pub page_index: usize,
40    /// Item index within the page
41    pub item_index: usize,
42    /// Full area for this result
43    pub area: Rect,
44}
45
46/// Layout info for a setting item
47#[derive(Debug, Clone)]
48pub struct ItemLayout {
49    /// Item index within current page
50    pub index: usize,
51    /// JSON path for this setting
52    pub path: String,
53    /// Full item area (for selection)
54    pub area: Rect,
55    /// Control-specific layout info
56    pub control: ControlLayoutInfo,
57}
58
59impl SettingsLayout {
60    /// Create a new layout for the given modal area
61    pub fn new(modal_area: Rect) -> Self {
62        Self {
63            modal_area,
64            categories: Vec::new(),
65            items: Vec::new(),
66            search_results: Vec::new(),
67            layer_button: None,
68            edit_button: None,
69            save_button: None,
70            cancel_button: None,
71            reset_button: None,
72            settings_panel_area: None,
73            scrollbar_area: None,
74        }
75    }
76
77    /// Add a category to the layout
78    pub fn add_category(&mut self, index: usize, area: Rect) {
79        self.categories.push((index, area));
80    }
81
82    /// Add a setting item to the layout
83    pub fn add_item(&mut self, index: usize, path: String, area: Rect, control: ControlLayoutInfo) {
84        self.items.push(ItemLayout {
85            index,
86            path,
87            area,
88            control,
89        });
90    }
91
92    /// Add a search result to the layout
93    pub fn add_search_result(&mut self, page_index: usize, item_index: usize, area: Rect) {
94        self.search_results.push(SearchResultLayout {
95            page_index,
96            item_index,
97            area,
98        });
99    }
100
101    /// Hit test a position and return what was clicked
102    pub fn hit_test(&self, x: u16, y: u16) -> Option<SettingsHit> {
103        // Check if outside modal
104        if !self.contains(self.modal_area, x, y) {
105            return Some(SettingsHit::Outside);
106        }
107
108        // Check footer buttons
109        if let Some(ref layer) = self.layer_button {
110            if self.contains(*layer, x, y) {
111                return Some(SettingsHit::LayerButton);
112            }
113        }
114        if let Some(ref edit) = self.edit_button {
115            if self.contains(*edit, x, y) {
116                return Some(SettingsHit::EditButton);
117            }
118        }
119        if let Some(ref save) = self.save_button {
120            if self.contains(*save, x, y) {
121                return Some(SettingsHit::SaveButton);
122            }
123        }
124        if let Some(ref cancel) = self.cancel_button {
125            if self.contains(*cancel, x, y) {
126                return Some(SettingsHit::CancelButton);
127            }
128        }
129        if let Some(ref reset) = self.reset_button {
130            if self.contains(*reset, x, y) {
131                return Some(SettingsHit::ResetButton);
132            }
133        }
134
135        // Check categories
136        for (index, area) in &self.categories {
137            if self.contains(*area, x, y) {
138                return Some(SettingsHit::Category(*index));
139            }
140        }
141
142        // Check setting items
143        for item in &self.items {
144            if self.contains(item.area, x, y) {
145                // Check specific control areas
146                match &item.control {
147                    ControlLayoutInfo::Toggle(toggle_area) => {
148                        if self.contains(*toggle_area, x, y) {
149                            return Some(SettingsHit::ControlToggle(item.index));
150                        }
151                    }
152                    ControlLayoutInfo::Number {
153                        decrement,
154                        increment,
155                        value,
156                    } => {
157                        if self.contains(*decrement, x, y) {
158                            return Some(SettingsHit::ControlDecrement(item.index));
159                        }
160                        if self.contains(*increment, x, y) {
161                            return Some(SettingsHit::ControlIncrement(item.index));
162                        }
163                        if self.contains(*value, x, y) {
164                            return Some(SettingsHit::Item(item.index));
165                        }
166                    }
167                    ControlLayoutInfo::Dropdown(area) => {
168                        if self.contains(*area, x, y) {
169                            return Some(SettingsHit::ControlDropdown(item.index));
170                        }
171                    }
172                    ControlLayoutInfo::Text(area) => {
173                        if self.contains(*area, x, y) {
174                            return Some(SettingsHit::ControlText(item.index));
175                        }
176                    }
177                    ControlLayoutInfo::TextList { rows } => {
178                        for (row_idx, row_area) in rows.iter().enumerate() {
179                            if self.contains(*row_area, x, y) {
180                                return Some(SettingsHit::ControlTextListRow(item.index, row_idx));
181                            }
182                        }
183                    }
184                    ControlLayoutInfo::Map {
185                        entry_rows,
186                        add_row_area,
187                    } => {
188                        // Check click on add-new row first (so it has priority)
189                        if let Some(add_area) = add_row_area {
190                            if self.contains(*add_area, x, y) {
191                                return Some(SettingsHit::ControlMapAddNew(item.index));
192                            }
193                        }
194                        for (row_idx, row_area) in entry_rows.iter().enumerate() {
195                            if self.contains(*row_area, x, y) {
196                                return Some(SettingsHit::ControlMapRow(item.index, row_idx));
197                            }
198                        }
199                    }
200                    ControlLayoutInfo::ObjectArray { entry_rows } => {
201                        for (row_idx, row_area) in entry_rows.iter().enumerate() {
202                            if self.contains(*row_area, x, y) {
203                                return Some(SettingsHit::ControlMapRow(item.index, row_idx));
204                            }
205                        }
206                    }
207                    ControlLayoutInfo::Json { edit_area } => {
208                        if self.contains(*edit_area, x, y) {
209                            return Some(SettingsHit::ControlText(item.index));
210                        }
211                    }
212                    ControlLayoutInfo::Complex => {}
213                }
214
215                return Some(SettingsHit::Item(item.index));
216            }
217        }
218
219        // Check scrollbar area (for drag detection)
220        if let Some(ref scrollbar) = self.scrollbar_area {
221            if self.contains(*scrollbar, x, y) {
222                return Some(SettingsHit::Scrollbar);
223            }
224        }
225
226        // Check settings panel area (for scroll wheel)
227        if let Some(ref panel) = self.settings_panel_area {
228            if self.contains(*panel, x, y) {
229                return Some(SettingsHit::SettingsPanel);
230            }
231        }
232
233        Some(SettingsHit::Background)
234    }
235
236    /// Check if a point is within a rectangle
237    fn contains(&self, rect: Rect, x: u16, y: u16) -> bool {
238        x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
239    }
240}
241
242/// Result of a hit test on the settings UI
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum SettingsHit {
245    /// Click outside the modal
246    Outside,
247    /// Click on modal background
248    Background,
249    /// Click on a category (index)
250    Category(usize),
251    /// Click on a setting item (index)
252    Item(usize),
253    /// Click on toggle control
254    ControlToggle(usize),
255    /// Click on number decrement button
256    ControlDecrement(usize),
257    /// Click on number increment button
258    ControlIncrement(usize),
259    /// Click on dropdown button
260    ControlDropdown(usize),
261    /// Click on text input
262    ControlText(usize),
263    /// Click on text list row (item_idx, row_idx)
264    ControlTextListRow(usize, usize),
265    /// Click on map row (item_idx, row_idx)
266    ControlMapRow(usize, usize),
267    /// Click on map add-new row (item_idx)
268    ControlMapAddNew(usize),
269    /// Click on layer button
270    LayerButton,
271    /// Click on edit config file button
272    EditButton,
273    /// Click on save button
274    SaveButton,
275    /// Click on cancel button
276    CancelButton,
277    /// Click on reset button
278    ResetButton,
279    /// Click on settings panel scrollbar
280    Scrollbar,
281    /// Click on settings panel (scrollable area)
282    SettingsPanel,
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_layout_creation() {
291        let modal = Rect::new(10, 5, 80, 30);
292        let mut layout = SettingsLayout::new(modal);
293
294        layout.add_category(0, Rect::new(11, 6, 20, 1));
295        layout.add_category(1, Rect::new(11, 7, 20, 1));
296
297        assert_eq!(layout.categories.len(), 2);
298    }
299
300    #[test]
301    fn test_hit_test_outside() {
302        let modal = Rect::new(10, 5, 80, 30);
303        let layout = SettingsLayout::new(modal);
304
305        assert_eq!(layout.hit_test(0, 0), Some(SettingsHit::Outside));
306        assert_eq!(layout.hit_test(5, 5), Some(SettingsHit::Outside));
307    }
308
309    #[test]
310    fn test_hit_test_category() {
311        let modal = Rect::new(10, 5, 80, 30);
312        let mut layout = SettingsLayout::new(modal);
313
314        layout.add_category(0, Rect::new(11, 6, 20, 1));
315        layout.add_category(1, Rect::new(11, 7, 20, 1));
316
317        assert_eq!(layout.hit_test(15, 6), Some(SettingsHit::Category(0)));
318        assert_eq!(layout.hit_test(15, 7), Some(SettingsHit::Category(1)));
319    }
320
321    #[test]
322    fn test_hit_test_buttons() {
323        let modal = Rect::new(10, 5, 80, 30);
324        let mut layout = SettingsLayout::new(modal);
325
326        layout.save_button = Some(Rect::new(60, 32, 8, 1));
327        layout.cancel_button = Some(Rect::new(70, 32, 10, 1));
328
329        assert_eq!(layout.hit_test(62, 32), Some(SettingsHit::SaveButton));
330        assert_eq!(layout.hit_test(75, 32), Some(SettingsHit::CancelButton));
331    }
332
333    #[test]
334    fn test_hit_test_item_with_toggle() {
335        let modal = Rect::new(10, 5, 80, 30);
336        let mut layout = SettingsLayout::new(modal);
337
338        layout.add_item(
339            0,
340            "/test".to_string(),
341            Rect::new(35, 10, 50, 2),
342            ControlLayoutInfo::Toggle(Rect::new(37, 11, 15, 1)),
343        );
344
345        // Click on toggle control
346        assert_eq!(layout.hit_test(40, 11), Some(SettingsHit::ControlToggle(0)));
347
348        // Click on item but not on toggle
349        assert_eq!(layout.hit_test(35, 10), Some(SettingsHit::Item(0)));
350    }
351}