Skip to main content

qframe/widgets/
settings_list.rs

1//! Settings lists: rows of a label on the left and a control anchored on the right.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{Axis, EventCx, Flex, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
10
11use super::cells;
12use super::row::LEAD;
13
14/// Cells between the label column and the control, and after the control.
15const CONTROL_GAP: u16 = 2;
16
17/// One setting of a [`SettingsList`]: a label, an optional description and the control added
18/// with [`SettingsRows::row`].
19pub struct SettingRow<Msg> {
20    label: String,
21    description: Option<String>,
22    disabled: bool,
23    on_activate: Option<Msg>,
24}
25
26impl<Msg> SettingRow<Msg> {
27    /// A row labelled `label`.
28    #[must_use]
29    pub fn new(label: impl Into<String>) -> Self {
30        Self { label: label.into(), description: None, disabled: false, on_activate: None }
31    }
32
33    /// One faint line under the label.
34    #[must_use]
35    pub fn description(mut self, description: impl Into<String>) -> Self {
36        self.description = Some(description.into());
37        self
38    }
39
40    /// Greys the row out; the keyboard skips it. Disable its control as well.
41    #[must_use]
42    pub fn disabled(mut self, disabled: bool) -> Self {
43        self.disabled = disabled;
44        self
45    }
46
47    /// Message for Enter or Space on the row when its control does not use the key, or for a
48    /// click on the label; for rows that open something, such as a detail page.
49    #[must_use]
50    pub fn on_activate(mut self, message: Msg) -> Self {
51        self.on_activate = Some(message);
52        self
53    }
54
55    fn height(&self) -> u16 {
56        1 + u16::from(self.description.is_some())
57    }
58}
59
60enum Entry<Msg> {
61    Heading(String),
62    /// A setting and the index of its control node.
63    Row(SettingRow<Msg>, usize),
64}
65
66/// Adds headings and rows to a [`SettingsList`] inside [`SettingsList::show`].
67pub struct SettingsRows<'a, Msg> {
68    entries: Vec<Entry<Msg>>,
69    controls: Vec<Node<Msg>>,
70    env: &'a crate::env::Env,
71    size: crate::geometry::Size,
72    idle: &'a crate::widget::IdleScope<Msg>,
73}
74
75impl<Msg: 'static> SettingsRows<'_, Msg> {
76    /// Adds a group heading.
77    pub fn heading(&mut self, title: impl Into<String>) {
78        self.entries.push(Entry::Heading(title.into()));
79    }
80
81    /// Adds `row` with the one control built by `control` (a switch, a select, a segmented control,
82    /// a value text). Give the control no focus handling of its own: the list takes focus as
83    /// one control and passes keys to the selected row.
84    pub fn row(&mut self, row: SettingRow<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) {
85        let mut children = Vec::new();
86        control(&mut View::new(&mut children, self.env, self.size, self.idle));
87        let index = self.controls.len();
88        self.controls.push(Node::new(Flex::new(Axis::Row, children), index));
89        self.entries.push(Entry::Row(row, index));
90    }
91}
92
93/// Settings, one per row: the label (and a faint description) on the left, its control
94/// anchored on the right.
95///
96/// Rows are bare until touched. The row under the pointer raises its surface with a soft
97/// pillar; the keyboard's row, while the list has focus, raises it further with a breathing
98/// pillar. A focused list raises only one row: moving the pointer onto a row makes it the
99/// keyboard's row. Only the label slides one cell right; the pillar and the control never move.
100/// The label column keeps one spare cell for that and cuts long labels with `…`.
101///
102/// The list takes focus as one control. ↑/↓ (and Home/End) move between enabled rows; every
103/// other key goes to the selected row's control, so Enter or Space toggles a switch or opens a
104/// select and ←/→ change a segmented control. Keys the control does not use activate the row
105/// when it has [`SettingRow::on_activate`]. The pointer works on controls directly; clicking a
106/// label selects its row. The application owns every value; the keyboard's row lives in the
107/// runtime.
108///
109/// Style keys: `setting-row` (`bg`, `pillar`) with `hover`, `selected`, `focus`, `disabled`;
110/// `setting-label` (`fg`, `bold`) and `setting-description` (`fg`) with the same states;
111/// `settings-heading` (`fg`, `bold`).
112pub struct SettingsList<Msg> {
113    entries: Vec<Entry<Msg>>,
114    controls: Vec<Node<Msg>>,
115}
116
117#[derive(Debug, Default)]
118struct SettingsMemory {
119    selected: Option<usize>,
120    /// Where each control was painted, by control index.
121    controls: Vec<Rect>,
122    /// Where each row was painted, by control index.
123    rows: Vec<Rect>,
124    /// Where the pointer was in the last frame; moving it carries the keyboard's row.
125    pointer: Option<(i32, i32)>,
126}
127
128impl<Msg: Clone + 'static> SettingsList<Msg> {
129    /// Adds a settings list with the headings and rows `build` adds to `ui`.
130    pub fn show<'v>(ui: &'v mut View<'_, Msg>, build: impl FnOnce(&mut SettingsRows<'_, Msg>)) -> NodeMut<'v, Msg> {
131        let (entries, controls) = {
132            let mut rows = SettingsRows {
133                entries: Vec::new(),
134                controls: Vec::new(),
135                env: ui.env(),
136                size: ui.size(),
137                idle: ui.idle_scope(),
138            };
139            build(&mut rows);
140            (rows.entries, rows.controls)
141        };
142        ui.add(Self { entries, controls }).fill_width()
143    }
144
145    fn row(&self, index: usize) -> Option<&SettingRow<Msg>> {
146        self.entries.iter().find_map(|entry| match entry {
147            Entry::Row(row, i) if *i == index => Some(row),
148            _ => None,
149        })
150    }
151
152    fn enabled(&self) -> Vec<usize> {
153        self.entries
154            .iter()
155            .filter_map(|entry| match entry {
156                Entry::Row(row, index) if !row.disabled => Some(*index),
157                _ => None,
158            })
159            .collect()
160    }
161
162    /// The keyboard's row: the remembered one when it is still enabled, else the first enabled.
163    fn current(&self, remembered: Option<usize>) -> Option<usize> {
164        let enabled = self.enabled();
165        remembered.filter(|index| enabled.contains(index)).or_else(|| enabled.first().copied())
166    }
167
168    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
169        match self.row(index).and_then(|row| row.on_activate.clone()) {
170            Some(message) => {
171                cx.flash();
172                cx.emit(message);
173                true
174            }
175            None => false,
176        }
177    }
178}
179
180impl<Msg: Clone + 'static> Widget<Msg> for SettingsList<Msg> {
181    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
182        let mut width = 0u16;
183        let mut height = 0u16;
184        for (position, entry) in self.entries.iter().enumerate() {
185            match entry {
186                Entry::Heading(title) => {
187                    height = height.saturating_add(1 + u16::from(position > 0));
188                    width = width.max(text::width(title).saturating_add(LEAD));
189                }
190                Entry::Row(row, index) => {
191                    let control = cx.measure_child(&self.controls[*index], Size::new(available.width, 1)).width;
192                    let label = text::width(&row.label).max(row.description.as_deref().map_or(0, text::width));
193                    width = width.max(cells::sum([LEAD, label, 1, CONTROL_GAP * 2, control]));
194                    height = height.saturating_add(row.height());
195                }
196            }
197        }
198        Size::new(width, height).min(available)
199    }
200
201    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
202        cx.register_hit(area);
203        let focused = cx.is_focused();
204        let pointer = cx.pointer_within();
205        let slide = cx.env().slide();
206        let enabled = self.enabled();
207        let selected = {
208            let memory = cx.memory::<SettingsMemory>();
209            // The pointer moves the one highlight: an enabled row it moves onto becomes the
210            // keyboard's row, so a focused list never raises two rows at once.
211            if pointer != memory.pointer {
212                memory.pointer = pointer;
213                let under = pointer.and_then(|(px, py)| memory.rows.iter().position(|rect| rect.contains(px, py)));
214                if let Some(index) = under.filter(|index| enabled.contains(index)) {
215                    memory.selected = Some(index);
216                }
217            }
218            let current = self.current(memory.selected);
219            memory.selected = current;
220            current.filter(|_| focused)
221        };
222        let mut controls = vec![Rect::default(); self.controls.len()];
223        let mut rows = vec![Rect::default(); self.controls.len()];
224        let mut y = area.y;
225        for (position, entry) in self.entries.iter().enumerate() {
226            match entry {
227                Entry::Heading(title) => {
228                    if position > 0 {
229                        y += 1;
230                    }
231                    let style = cx.style("settings-heading", None, &[]).text();
232                    let budget = area.width.saturating_sub(LEAD + 1);
233                    let shown = text::truncate(title, budget).into_owned();
234                    cx.text(area.x + i32::from(LEAD), y, &shown, style, budget);
235                    y += 1;
236                }
237                Entry::Row(row, index) => {
238                    let rect = Rect::new(area.x, y, area.width, row.height());
239                    y += i32::from(row.height());
240                    rows[*index] = rect;
241                    let mut states = Vec::new();
242                    if row.disabled {
243                        states.push(State::Disabled);
244                    } else {
245                        let pointed = pointer.is_some_and(|(px, py)| rect.contains(px, py));
246                        if pointed && (!focused || selected == Some(*index)) {
247                            states.push(State::Hover);
248                        }
249                        if selected == Some(*index) {
250                            states.extend([State::Selected, State::Focus]);
251                        }
252                    }
253                    let style = cx.style("setting-row", None, &states);
254                    if let Some(bg) = style.text().bg {
255                        cx.clear(rect, bg);
256                    }
257                    if let Some(color) = style.color("pillar") {
258                        for row_y in rect.y..rect.bottom() {
259                            cx.pillar(rect.x, row_y, color);
260                        }
261                    }
262
263                    let node = &self.controls[*index];
264                    let control_width = cx.measure_child(node, Size::new(area.width / 2, 1)).width;
265                    let control_x = rect.right() - i32::from(CONTROL_GAP + control_width);
266                    let control = Rect::new(control_x, rect.y, control_width, 1);
267                    controls[*index] = control;
268                    cx.paint_child_unfocusable(node, control);
269
270                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
271                    let shift = u16::from(slide && raised);
272                    let text_x = rect.x + i32::from(LEAD);
273                    // The label column keeps one spare cell so the slide never reaches the control.
274                    let budget = clamp_u16(control_x - i32::from(CONTROL_GAP) - text_x).saturating_sub(1);
275                    let x = text_x + i32::from(shift);
276                    let label_style = cx.style("setting-label", None, &states).text();
277                    let label = text::truncate(&row.label, budget).into_owned();
278                    cx.text(x, rect.y, &label, CellStyle { bg: None, ..label_style }, budget);
279                    if let Some(description) = &row.description {
280                        let style = cx.style("setting-description", None, &states).text();
281                        let shown = text::truncate(description, budget).into_owned();
282                        cx.text(x, rect.y + 1, &shown, CellStyle { bg: None, ..style }, budget);
283                    }
284                }
285            }
286        }
287        let memory = cx.memory::<SettingsMemory>();
288        memory.controls = controls;
289        memory.rows = rows;
290    }
291
292    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
293        let enabled = self.enabled();
294        if enabled.is_empty() {
295            return false;
296        }
297        let current = self.current(cx.memory::<SettingsMemory>().selected);
298        match event {
299            Event::Key(key) => {
300                let position = current.and_then(|index| enabled.iter().position(|i| *i == index)).unwrap_or(0);
301                let target = if key.is_plain(Key::Up) {
302                    Some(position.saturating_sub(1))
303                } else if key.is_plain(Key::Down) {
304                    Some((position + 1).min(enabled.len() - 1))
305                } else {
306                    None
307                };
308                if let Some(target) = target {
309                    cx.memory::<SettingsMemory>().selected = Some(enabled[target]);
310                    return true;
311                }
312                let Some(index) = current else {
313                    return false;
314                };
315                let rect = cx.memory::<SettingsMemory>().controls.get(index).copied().unwrap_or_default();
316                // The row holds one control inside its layout node; that control gets the key.
317                let used =
318                    self.controls[index].widget.children().iter().any(|control| cx.forward(control, rect, event));
319                if used {
320                    return true;
321                }
322                if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
323                    return self.activate(cx, index);
324                }
325                if key.is_plain(Key::Home) || key.is_plain(Key::End) {
326                    let target = if key.is_plain(Key::Home) { enabled[0] } else { enabled[enabled.len() - 1] };
327                    cx.memory::<SettingsMemory>().selected = Some(target);
328                    return true;
329                }
330                false
331            }
332            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
333                let rows = cx.memory::<SettingsMemory>().rows.clone();
334                let Some(index) = rows.iter().position(|rect| rect.contains(mouse.x, mouse.y)) else {
335                    return false;
336                };
337                if !enabled.contains(&index) {
338                    return true;
339                }
340                cx.memory::<SettingsMemory>().selected = Some(index);
341                cx.request_focus();
342                self.activate(cx, index);
343                true
344            }
345            _ => false,
346        }
347    }
348
349    fn focusable(&self) -> bool {
350        !self.enabled().is_empty()
351    }
352
353    fn children(&self) -> &[Node<Msg>] {
354        &self.controls
355    }
356
357    fn children_mut(&mut self) -> &mut [Node<Msg>] {
358        &mut self.controls
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::runtime::{App, Command, Harness};
366    use crate::widgets::{Segmented, Switch};
367
368    #[derive(Default)]
369    struct Prefs {
370        animations: bool,
371        density: usize,
372        opened: usize,
373        telemetry_locked: bool,
374    }
375
376    #[derive(Clone)]
377    enum Msg {
378        Animations(bool),
379        Density(usize),
380        Open,
381    }
382
383    impl App for Prefs {
384        type Msg = Msg;
385        fn update(&mut self, msg: Msg) -> Command<Msg> {
386            match msg {
387                Msg::Animations(on) => self.animations = on,
388                Msg::Density(index) => self.density = index,
389                Msg::Open => self.opened += 1,
390            }
391            Command::none()
392        }
393        fn view(&self, ui: &mut View<'_, Msg>) {
394            SettingsList::show(ui, |list| {
395                list.heading("APPEARANCE");
396                list.row(SettingRow::new("Animations").description("Motion in lists"), |ui| {
397                    ui.add(Switch::new(self.animations).on_toggle(Msg::Animations));
398                });
399                list.row(SettingRow::new("Density"), |ui| {
400                    ui.add(Segmented::new(["Cozy", "Compact"]).selected(self.density).on_select(Msg::Density));
401                });
402                list.heading("PRIVACY");
403                list.row(SettingRow::new("Telemetry").disabled(self.telemetry_locked), |ui| {
404                    ui.add(Switch::new(false).disabled(self.telemetry_locked));
405                });
406                list.row(SettingRow::new("Storage used by images and volumes").on_activate(Msg::Open), |ui| {
407                    ui.add(Text::new("2.4 GB"));
408                });
409            })
410            .id("settings");
411        }
412    }
413
414    use crate::widgets::Text;
415
416    #[test]
417    fn labels_left_controls_anchored_right_with_headings() {
418        let h = Harness::new(Prefs::default(), 40, 8);
419        assert_eq!(
420            h.screen(),
421            "  APPEARANCE\n  Animations                      \n  Motion in lists\n  Density            Cozy    Compact\n\n  PRIVACY\n  Telemetry\n  Storage used by images and…   2.4 GB\n"
422                .lines()
423                .map(str::trim_end)
424                .collect::<Vec<_>>()
425                .join("\n")
426                + "\n"
427        );
428    }
429
430    #[test]
431    fn keyboard_moves_rows_and_drives_the_selected_control() {
432        let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 8);
433        h.press("tab");
434        let theme = h.env().theme();
435        assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
436        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
437        h.press("space");
438        assert!(h.app().animations);
439        h.press("down").press("right");
440        assert_eq!(h.app().density, 1);
441        h.press("down").press("enter");
442        assert_eq!(h.app().opened, 1, "the disabled row is skipped");
443        h.press("up");
444        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
445    }
446
447    #[test]
448    fn the_pointer_carries_the_keyboards_row() {
449        let mut h = Harness::new(Prefs::default(), 40, 8);
450        h.press("tab");
451        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
452        h.hover(6, 7);
453        let screen = h.screen();
454        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
455        assert_eq!(raised, ["▌  Storage used by images and…  2.4 GB"], "one raised row:\n{screen}");
456        assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
457        assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
458        h.press("up");
459        let screen = h.screen();
460        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
461        assert_eq!(raised, ["▌  Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
462    }
463
464    #[test]
465    fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
466        let mut h = Harness::new(Prefs::default(), 40, 8);
467        let before = h.find("Cozy");
468        h.hover(4, 3);
469        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
470        assert_eq!(h.find("Cozy"), before);
471        h.hover(before.map_or(0, |(x, _)| x), 3);
472        assert!(
473            h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
474            "the row stays lit over its control"
475        );
476        h.click_text("Compact");
477        assert_eq!(h.app().density, 1);
478        h.click_text("Storage");
479        assert_eq!(h.app().opened, 1);
480    }
481}