Skip to main content

rusticity_term/
preferences.rs

1//! Preferences rows are the single source of truth for rendering and input.
2//! This model has no dependency on App or Ratatui; service bindings live separately.
3use crate::common::PageSize;
4use std::borrow::Cow;
5
6pub(crate) mod bindings;
7mod panels;
8
9pub const PAGE_SIZE_OPTIONS: &[(PageSize, &str)] = &[
10    (PageSize::Ten, "10"),
11    (PageSize::TwentyFive, "25"),
12    (PageSize::Fifty, "50"),
13    (PageSize::OneHundred, "100"),
14];
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct PreferenceColumn {
18    pub id: Cow<'static, str>,
19    pub label: String,
20    pub visible: bool,
21    pub locked: bool,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum PreferenceRow {
26    Section(&'static str),
27    Column(PreferenceColumn),
28    Separator,
29    Radio {
30        group: &'static str,
31        value: &'static str,
32        label: &'static str,
33        selected: bool,
34    },
35    Toggle {
36        id: &'static str,
37        label: &'static str,
38        enabled: bool,
39    },
40    PageSize {
41        value: PageSize,
42        label: &'static str,
43        selected: bool,
44    },
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub enum PreferenceAction {
49    SetColumnVisibility {
50        id: Cow<'static, str>,
51        visible: bool,
52    },
53    SetPageSize(PageSize),
54    SetRadio {
55        group: &'static str,
56        value: &'static str,
57    },
58    SetToggle {
59        id: &'static str,
60        enabled: bool,
61    },
62}
63
64#[derive(Debug)]
65pub struct Preferences {
66    rows: Vec<PreferenceRow>,
67    enabled: bool,
68}
69
70impl Preferences {
71    pub fn table(columns: Vec<PreferenceColumn>, page_size: PageSize) -> Self {
72        Self::table_with_page_sizes(columns, page_size, PAGE_SIZE_OPTIONS)
73    }
74
75    pub fn table_with_page_sizes(
76        columns: Vec<PreferenceColumn>,
77        page_size: PageSize,
78        options: &[(PageSize, &'static str)],
79    ) -> Self {
80        Self::columns_only(columns, true).with_page_sizes(Some(page_size), options)
81    }
82
83    pub fn columns_only(columns: Vec<PreferenceColumn>, header: bool) -> Self {
84        let mut rows = Vec::new();
85        if header {
86            rows.push(PreferenceRow::Section("Columns"));
87        }
88        rows.extend(columns.into_iter().map(PreferenceRow::Column));
89        Self {
90            rows,
91            enabled: true,
92        }
93    }
94
95    fn section(&mut self, title: &'static str) {
96        if !self.rows.is_empty() {
97            self.rows.push(PreferenceRow::Separator);
98        }
99        self.rows.push(PreferenceRow::Section(title));
100    }
101
102    pub fn with_page_sizes(
103        mut self,
104        current: Option<PageSize>,
105        options: &[(PageSize, &'static str)],
106    ) -> Self {
107        self.section("Page size");
108        self.rows.extend(
109            options
110                .iter()
111                .map(|&(value, label)| PreferenceRow::PageSize {
112                    value,
113                    label,
114                    selected: current == Some(value),
115                }),
116        );
117        self
118    }
119
120    pub fn radio_group(
121        mut self,
122        group: &'static str,
123        title: &'static str,
124        choices: &[(&'static str, &'static str)],
125        selected: &str,
126    ) -> Self {
127        self.section(title);
128        self.rows
129            .extend(choices.iter().map(|&(value, label)| PreferenceRow::Radio {
130                group,
131                value,
132                label,
133                selected: selected == value,
134            }));
135        self
136    }
137
138    pub fn toggle(
139        mut self,
140        id: &'static str,
141        title: &'static str,
142        label: &'static str,
143        enabled: bool,
144    ) -> Self {
145        self.section(title);
146        self.rows.push(PreferenceRow::Toggle { id, label, enabled });
147        self
148    }
149
150    /// Preserve a non-table tab's fallback display without presenting working controls.
151    pub fn read_only(mut self) -> Self {
152        self.enabled = false;
153        self
154    }
155
156    pub fn is_enabled(&self) -> bool {
157        self.enabled
158    }
159    pub fn rows(&self) -> &[PreferenceRow] {
160        &self.rows
161    }
162    pub fn max_index(&self) -> usize {
163        self.rows.len().saturating_sub(1)
164    }
165    pub fn columns(&self) -> impl Iterator<Item = &PreferenceColumn> {
166        self.rows.iter().filter_map(|row| match row {
167            PreferenceRow::Column(column) => Some(column),
168            _ => None,
169        })
170    }
171    pub fn column_count(&self) -> usize {
172        self.columns().count()
173    }
174    pub fn is_separator(&self, index: usize) -> bool {
175        matches!(self.rows.get(index), Some(PreferenceRow::Separator))
176    }
177
178    /// Headers, separators, locked columns, and stale indices have no action.
179    /// Count only columns in this model, not stale IDs from a previous result set.
180    pub fn action(&self, index: usize) -> Option<PreferenceAction> {
181        if !self.enabled {
182            return None;
183        }
184        match self.rows.get(index)? {
185            PreferenceRow::Column(column) if !column.locked => {
186                if column.visible && self.columns().filter(|c| c.visible).count() <= 1 {
187                    return None;
188                }
189                Some(PreferenceAction::SetColumnVisibility {
190                    id: column.id.clone(),
191                    visible: !column.visible,
192                })
193            }
194            PreferenceRow::PageSize { value, .. } => Some(PreferenceAction::SetPageSize(*value)),
195            PreferenceRow::Radio { group, value, .. } => {
196                Some(PreferenceAction::SetRadio { group, value })
197            }
198            PreferenceRow::Toggle { id, enabled, .. } => Some(PreferenceAction::SetToggle {
199                id,
200                enabled: !enabled,
201            }),
202            _ => None,
203        }
204    }
205
206    /// Up/down saturates at the ends and skips separators, including stale cursors.
207    pub fn move_row(&self, index: usize, forward: bool) -> usize {
208        let index = index.min(self.max_index());
209        if forward {
210            (index.saturating_add(1)..self.rows.len())
211                .find(|&i| !self.is_separator(i))
212                .unwrap_or(index)
213        } else {
214            (0..index)
215                .rev()
216                .find(|&i| !self.is_separator(i))
217                .unwrap_or(index)
218        }
219    }
220
221    /// Tab/BackTab cycles section headers, not individual options.
222    pub fn move_section(&self, index: usize, forward: bool) -> usize {
223        let sections: Vec<_> = self
224            .rows
225            .iter()
226            .enumerate()
227            .filter_map(|(i, row)| matches!(row, PreferenceRow::Section(_)).then_some(i))
228            .collect();
229        if sections.is_empty() {
230            return 0;
231        }
232        let current = sections.iter().rposition(|&i| i <= index).unwrap_or(0);
233        let next = if forward {
234            (current + 1) % sections.len()
235        } else {
236            (current + sections.len() - 1) % sections.len()
237        };
238        sections[next]
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn model(n: usize) -> Preferences {
247        Preferences::table(
248            (0..n)
249                .map(|i| PreferenceColumn {
250                    id: format!("attribute-{i}").into(),
251                    label: format!("Attribute {i}"),
252                    visible: true,
253                    locked: false,
254                })
255                .collect(),
256            PageSize::Fifty,
257        )
258    }
259
260    #[test]
261    fn every_rendered_row_has_matching_bounds_and_action() {
262        for n in [0, 1, 2, 18, 100] {
263            let prefs = model(n);
264            assert_eq!(prefs.column_count(), n);
265            assert_eq!(prefs.max_index(), prefs.rows().len() - 1);
266            assert_eq!(prefs.action(0), None);
267            assert!(prefs.is_separator(n + 1));
268            assert_eq!(prefs.action(n + 1), None);
269            assert_eq!(prefs.action(n + 2), None);
270            for (offset, &(size, _)) in PAGE_SIZE_OPTIONS.iter().enumerate() {
271                assert_eq!(
272                    prefs.action(n + 3 + offset),
273                    Some(PreferenceAction::SetPageSize(size))
274                );
275            }
276            assert_eq!(prefs.action(prefs.rows().len()), None);
277            assert_eq!(prefs.action(usize::MAX), None);
278        }
279    }
280
281    #[test]
282    fn navigation_uses_rows_and_sections() {
283        let prefs = model(2);
284        assert_eq!(prefs.move_row(2, true), 4);
285        assert_eq!(prefs.move_row(4, false), 2);
286        assert_eq!(prefs.move_row(0, false), 0);
287        assert_eq!(prefs.move_row(usize::MAX, true), prefs.max_index());
288        for index in 0..=prefs.max_index() {
289            let expected = if index < 4 { 4 } else { 0 };
290            assert_eq!(prefs.move_section(index, true), expected);
291            assert_eq!(prefs.move_section(index, false), expected);
292        }
293    }
294
295    #[test]
296    fn locked_and_last_visible_columns_cannot_be_hidden() {
297        let mut prefs = model(2);
298        if let PreferenceRow::Column(column) = &mut prefs.rows[1] {
299            column.locked = true;
300        }
301        assert_eq!(prefs.action(1), None);
302        assert!(matches!(
303            prefs.action(2),
304            Some(PreferenceAction::SetColumnVisibility { visible: false, .. })
305        ));
306        assert_eq!(model(1).action(1), None);
307        if let PreferenceRow::Column(column) = &mut prefs.rows[2] {
308            column.visible = false;
309        }
310        assert!(matches!(
311            prefs.action(2),
312            Some(PreferenceAction::SetColumnVisibility { visible: true, .. })
313        ));
314        let prefs = prefs.read_only();
315        assert!((0..prefs.rows.len()).all(|i| prefs.action(i).is_none()));
316    }
317}