Skip to main content

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 crate::view::ui::point_in_rect;
7use ratatui::layout::Rect;
8
9/// Layout information for the entire settings UI
10#[derive(Debug, Clone, Default)]
11pub struct SettingsLayout {
12    /// The modal area
13    pub modal_area: Rect,
14    /// Category list items (index, area)
15    pub categories: Vec<(usize, Rect)>,
16    /// Setting items (index, path, area, control_layout)
17    pub items: Vec<ItemLayout>,
18    /// Search result items (page_index, item_index, area)
19    pub search_results: Vec<SearchResultLayout>,
20    /// Layer button area
21    pub layer_button: Option<Rect>,
22    /// Edit config file button area
23    pub edit_button: Option<Rect>,
24    /// Save button area
25    pub save_button: Option<Rect>,
26    /// Cancel button area
27    pub cancel_button: Option<Rect>,
28    /// Reset button area
29    pub reset_button: Option<Rect>,
30    /// Clear category button area (shown in page header for nullable categories)
31    pub clear_category_button: Option<Rect>,
32    /// Settings panel area (for scroll hit testing)
33    pub settings_panel_area: Option<Rect>,
34    /// Scrollbar area (for drag detection)
35    pub scrollbar_area: Option<Rect>,
36    /// Search results scrollbar area (for search results scrolling)
37    pub search_scrollbar_area: Option<Rect>,
38    /// Search results content area (for scroll wheel detection)
39    pub search_results_area: Option<Rect>,
40}
41
42/// Layout info for a search result
43#[derive(Debug, Clone)]
44pub struct SearchResultLayout {
45    /// Page index (category)
46    pub page_index: usize,
47    /// Item index within the page
48    pub item_index: usize,
49    /// Full area for this result
50    pub area: Rect,
51}
52
53/// Layout info for a setting item
54#[derive(Debug, Clone)]
55pub struct ItemLayout {
56    /// Item index within current page
57    pub index: usize,
58    /// JSON path for this setting
59    pub path: String,
60    /// Full item area (for selection)
61    pub area: Rect,
62    /// Control-specific layout info
63    pub control: ControlLayoutInfo,
64    /// Area of the [Inherit] button (only for nullable, explicitly-set items)
65    pub inherit_button: Option<Rect>,
66}
67
68impl SettingsLayout {
69    /// Create a new layout for the given modal area
70    pub fn new(modal_area: Rect) -> Self {
71        Self {
72            modal_area,
73            categories: Vec::new(),
74            items: Vec::new(),
75            search_results: Vec::new(),
76            layer_button: None,
77            edit_button: None,
78            save_button: None,
79            cancel_button: None,
80            reset_button: None,
81            clear_category_button: None,
82            settings_panel_area: None,
83            scrollbar_area: None,
84            search_scrollbar_area: None,
85            search_results_area: None,
86        }
87    }
88
89    /// Add a category to the layout
90    pub fn add_category(&mut self, index: usize, area: Rect) {
91        self.categories.push((index, area));
92    }
93
94    /// Add a setting item to the layout
95    pub fn add_item(
96        &mut self,
97        index: usize,
98        path: String,
99        area: Rect,
100        control: ControlLayoutInfo,
101        inherit_button: Option<Rect>,
102    ) {
103        self.items.push(ItemLayout {
104            index,
105            path,
106            area,
107            control,
108            inherit_button,
109        });
110    }
111
112    /// Add a search result to the layout
113    pub fn add_search_result(&mut self, page_index: usize, item_index: usize, area: Rect) {
114        self.search_results.push(SearchResultLayout {
115            page_index,
116            item_index,
117            area,
118        });
119    }
120
121    /// Hit test a position and return what was clicked
122    pub fn hit_test(&self, x: u16, y: u16) -> Option<SettingsHit> {
123        // Check if outside modal
124        if !point_in_rect(self.modal_area, x, y) {
125            return Some(SettingsHit::Outside);
126        }
127
128        // Check footer buttons
129        if let Some(ref layer) = self.layer_button {
130            if point_in_rect(*layer, x, y) {
131                return Some(SettingsHit::LayerButton);
132            }
133        }
134        if let Some(ref edit) = self.edit_button {
135            if point_in_rect(*edit, x, y) {
136                return Some(SettingsHit::EditButton);
137            }
138        }
139        if let Some(ref save) = self.save_button {
140            if point_in_rect(*save, x, y) {
141                return Some(SettingsHit::SaveButton);
142            }
143        }
144        if let Some(ref cancel) = self.cancel_button {
145            if point_in_rect(*cancel, x, y) {
146                return Some(SettingsHit::CancelButton);
147            }
148        }
149        if let Some(ref reset) = self.reset_button {
150            if point_in_rect(*reset, x, y) {
151                return Some(SettingsHit::ResetButton);
152            }
153        }
154        if let Some(ref clear_cat) = self.clear_category_button {
155            if point_in_rect(*clear_cat, x, y) {
156                return Some(SettingsHit::ClearCategoryButton);
157            }
158        }
159
160        // Check categories
161        for (index, area) in &self.categories {
162            if point_in_rect(*area, x, y) {
163                return Some(SettingsHit::Category(*index));
164            }
165        }
166
167        // Check search scrollbar (before search results, for click/drag priority)
168        if let Some(ref scrollbar) = self.search_scrollbar_area {
169            if point_in_rect(*scrollbar, x, y) {
170                return Some(SettingsHit::SearchScrollbar);
171            }
172        }
173
174        // Check search results (before regular items, since they replace the item list during search)
175        for (idx, result) in self.search_results.iter().enumerate() {
176            if point_in_rect(result.area, x, y) {
177                return Some(SettingsHit::SearchResult(idx));
178            }
179        }
180
181        // Check search results area (for scroll wheel when over the results area but not on a result)
182        if let Some(ref area) = self.search_results_area {
183            if point_in_rect(*area, x, y) {
184                return Some(SettingsHit::SearchResultsPanel);
185            }
186        }
187
188        // Check setting items
189        for item in &self.items {
190            if point_in_rect(item.area, x, y) {
191                // Check inherit button first (highest priority within item)
192                if let Some(ref inherit_area) = item.inherit_button {
193                    if point_in_rect(*inherit_area, x, y) {
194                        return Some(SettingsHit::ControlInherit(item.index));
195                    }
196                }
197
198                // Check specific control areas
199                match &item.control {
200                    ControlLayoutInfo::Toggle(toggle_area) => {
201                        if point_in_rect(*toggle_area, x, y) {
202                            return Some(SettingsHit::ControlToggle(item.index));
203                        }
204                    }
205                    ControlLayoutInfo::Number {
206                        decrement,
207                        increment,
208                        value,
209                    } => {
210                        if point_in_rect(*decrement, x, y) {
211                            return Some(SettingsHit::ControlDecrement(item.index));
212                        }
213                        if point_in_rect(*increment, x, y) {
214                            return Some(SettingsHit::ControlIncrement(item.index));
215                        }
216                        if point_in_rect(*value, x, y) {
217                            return Some(SettingsHit::Item(item.index));
218                        }
219                    }
220                    ControlLayoutInfo::Dropdown {
221                        button_area,
222                        option_areas,
223                        scroll_offset,
224                    } => {
225                        // Check option areas first (when dropdown is open)
226                        for (i, area) in option_areas.iter().enumerate() {
227                            if point_in_rect(*area, x, y) {
228                                return Some(SettingsHit::ControlDropdownOption(
229                                    item.index,
230                                    scroll_offset + i,
231                                ));
232                            }
233                        }
234                        if point_in_rect(*button_area, x, y) {
235                            return Some(SettingsHit::ControlDropdown(item.index));
236                        }
237                    }
238                    ControlLayoutInfo::Text(area) => {
239                        if point_in_rect(*area, x, y) {
240                            return Some(SettingsHit::ControlText(item.index));
241                        }
242                    }
243                    ControlLayoutInfo::TextList { rows } => {
244                        for &(data_idx, row_area) in rows.iter() {
245                            if point_in_rect(row_area, x, y) {
246                                return Some(SettingsHit::ControlTextListRow(
247                                    item.index,
248                                    data_idx.unwrap_or(usize::MAX),
249                                ));
250                            }
251                        }
252                    }
253                    ControlLayoutInfo::Map {
254                        entry_rows,
255                        add_row_area,
256                    } => {
257                        // Check click on add-new row first (so it has priority)
258                        if let Some(add_area) = add_row_area {
259                            if point_in_rect(*add_area, x, y) {
260                                return Some(SettingsHit::ControlMapAddNew(item.index));
261                            }
262                        }
263                        for &(data_idx, row_area) in entry_rows.iter() {
264                            if point_in_rect(row_area, x, y) {
265                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
266                            }
267                        }
268                    }
269                    ControlLayoutInfo::ObjectArray { entry_rows } => {
270                        for &(data_idx, row_area) in entry_rows.iter() {
271                            if point_in_rect(row_area, x, y) {
272                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
273                            }
274                        }
275                    }
276                    ControlLayoutInfo::DualList(dual_layout) => {
277                        use crate::view::controls::DualListHit;
278                        if let Some(hit) = dual_layout.hit_test(x, y) {
279                            return Some(match hit {
280                                DualListHit::AvailableRow(row) => {
281                                    SettingsHit::ControlDualListAvailable(item.index, row)
282                                }
283                                DualListHit::IncludedRow(row) => {
284                                    SettingsHit::ControlDualListIncluded(item.index, row)
285                                }
286                                DualListHit::AddButton => {
287                                    SettingsHit::ControlDualListAdd(item.index)
288                                }
289                                DualListHit::RemoveButton => {
290                                    SettingsHit::ControlDualListRemove(item.index)
291                                }
292                                DualListHit::MoveUpButton => {
293                                    SettingsHit::ControlDualListMoveUp(item.index)
294                                }
295                                DualListHit::MoveDownButton => {
296                                    SettingsHit::ControlDualListMoveDown(item.index)
297                                }
298                            });
299                        }
300                    }
301                    ControlLayoutInfo::Json { edit_area } => {
302                        if point_in_rect(*edit_area, x, y) {
303                            return Some(SettingsHit::ControlText(item.index));
304                        }
305                    }
306                    ControlLayoutInfo::Complex => {}
307                }
308
309                return Some(SettingsHit::Item(item.index));
310            }
311        }
312
313        // Check scrollbar area (for drag detection)
314        if let Some(ref scrollbar) = self.scrollbar_area {
315            if point_in_rect(*scrollbar, x, y) {
316                return Some(SettingsHit::Scrollbar);
317            }
318        }
319
320        // Check settings panel area (for scroll wheel)
321        if let Some(ref panel) = self.settings_panel_area {
322            if point_in_rect(*panel, x, y) {
323                return Some(SettingsHit::SettingsPanel);
324            }
325        }
326
327        Some(SettingsHit::Background)
328    }
329}
330
331/// Result of a hit test on the settings UI
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum SettingsHit {
334    /// Click outside the modal
335    Outside,
336    /// Click on modal background
337    Background,
338    /// Click on a category (index)
339    Category(usize),
340    /// Click on a setting item (index)
341    Item(usize),
342    /// Click on a search result (index in search_results)
343    SearchResult(usize),
344    /// Click on toggle control
345    ControlToggle(usize),
346    /// Click on number decrement button
347    ControlDecrement(usize),
348    /// Click on number increment button
349    ControlIncrement(usize),
350    /// Click on dropdown button
351    ControlDropdown(usize),
352    /// Click on dropdown option (item_idx, option_idx)
353    ControlDropdownOption(usize, usize),
354    /// Click on text input
355    ControlText(usize),
356    /// Click on text list row (item_idx, row_idx)
357    ControlTextListRow(usize, usize),
358    /// Click on map row (item_idx, row_idx)
359    ControlMapRow(usize, usize),
360    /// Click on map add-new row (item_idx)
361    ControlMapAddNew(usize),
362    /// Click on inherit button (item_idx) - unset a nullable value
363    ControlInherit(usize),
364    /// Click on dual-list available row (item_idx, row_idx)
365    ControlDualListAvailable(usize, usize),
366    /// Click on dual-list included row (item_idx, row_idx)
367    ControlDualListIncluded(usize, usize),
368    /// Click on dual-list add button (item_idx)
369    ControlDualListAdd(usize),
370    /// Click on dual-list remove button (item_idx)
371    ControlDualListRemove(usize),
372    /// Click on dual-list move-up button (item_idx)
373    ControlDualListMoveUp(usize),
374    /// Click on dual-list move-down button (item_idx)
375    ControlDualListMoveDown(usize),
376    /// Click on layer button
377    LayerButton,
378    /// Click on edit config file button
379    EditButton,
380    /// Click on save button
381    SaveButton,
382    /// Click on cancel button
383    CancelButton,
384    /// Click on reset button
385    ResetButton,
386    /// Click on clear category button (for nullable categories)
387    ClearCategoryButton,
388    /// Click on settings panel scrollbar
389    Scrollbar,
390    /// Click on settings panel (scrollable area)
391    SettingsPanel,
392    /// Click on search results scrollbar
393    SearchScrollbar,
394    /// Click on search results area (for scroll wheel)
395    SearchResultsPanel,
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_layout_creation() {
404        let modal = Rect::new(10, 5, 80, 30);
405        let mut layout = SettingsLayout::new(modal);
406
407        layout.add_category(0, Rect::new(11, 6, 20, 1));
408        layout.add_category(1, Rect::new(11, 7, 20, 1));
409
410        assert_eq!(layout.categories.len(), 2);
411    }
412
413    #[test]
414    fn test_hit_test_outside() {
415        let modal = Rect::new(10, 5, 80, 30);
416        let layout = SettingsLayout::new(modal);
417
418        assert_eq!(layout.hit_test(0, 0), Some(SettingsHit::Outside));
419        assert_eq!(layout.hit_test(5, 5), Some(SettingsHit::Outside));
420    }
421
422    #[test]
423    fn test_hit_test_category() {
424        let modal = Rect::new(10, 5, 80, 30);
425        let mut layout = SettingsLayout::new(modal);
426
427        layout.add_category(0, Rect::new(11, 6, 20, 1));
428        layout.add_category(1, Rect::new(11, 7, 20, 1));
429
430        assert_eq!(layout.hit_test(15, 6), Some(SettingsHit::Category(0)));
431        assert_eq!(layout.hit_test(15, 7), Some(SettingsHit::Category(1)));
432    }
433
434    #[test]
435    fn test_hit_test_buttons() {
436        let modal = Rect::new(10, 5, 80, 30);
437        let mut layout = SettingsLayout::new(modal);
438
439        layout.save_button = Some(Rect::new(60, 32, 8, 1));
440        layout.cancel_button = Some(Rect::new(70, 32, 10, 1));
441
442        assert_eq!(layout.hit_test(62, 32), Some(SettingsHit::SaveButton));
443        assert_eq!(layout.hit_test(75, 32), Some(SettingsHit::CancelButton));
444    }
445
446    #[test]
447    fn test_hit_test_item_with_toggle() {
448        let modal = Rect::new(10, 5, 80, 30);
449        let mut layout = SettingsLayout::new(modal);
450
451        layout.add_item(
452            0,
453            "/test".to_string(),
454            Rect::new(35, 10, 50, 2),
455            ControlLayoutInfo::Toggle(Rect::new(37, 11, 15, 1)),
456            None,
457        );
458
459        // Click on toggle control
460        assert_eq!(layout.hit_test(40, 11), Some(SettingsHit::ControlToggle(0)));
461
462        // Click on item but not on toggle
463        assert_eq!(layout.hit_test(35, 10), Some(SettingsHit::Item(0)));
464    }
465}