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/// Cells a [nested](SettingRow::nested) row's text starts further in than its parent's.
18const NEST: u16 = 2;
19
20/// One setting of a [`SettingsList`]: a label, an optional description and the control added
21/// with [`SettingsRows::row`].
22pub struct SettingRow<Msg> {
23    label: String,
24    description: Option<String>,
25    disabled: bool,
26    nested: bool,
27    on_activate: Option<Msg>,
28}
29
30impl<Msg> SettingRow<Msg> {
31    /// A row labelled `label`.
32    #[must_use]
33    pub fn new(label: impl Into<String>) -> Self {
34        Self { label: label.into(), description: None, disabled: false, nested: false, on_activate: None }
35    }
36
37    /// A faint note under the label, wrapped over as many lines as it needs.
38    #[must_use]
39    pub fn description(mut self, description: impl Into<String>) -> Self {
40        self.description = Some(description.into());
41        self
42    }
43
44    /// Greys the row out; the keyboard skips it. Disable its control as well.
45    #[must_use]
46    pub fn disabled(mut self, disabled: bool) -> Self {
47        self.disabled = disabled;
48        self
49    }
50
51    /// Marks the row as part of the row above it, such as a choice that qualifies that setting:
52    /// its label and description start two cells further in. The pillar and the control stay
53    /// where every row has them, and the keys reach it as any other row.
54    #[must_use]
55    pub fn nested(mut self, nested: bool) -> Self {
56        self.nested = nested;
57        self
58    }
59
60    /// Cells the row's text starts further in than a top-level row's.
61    fn indent(&self) -> u16 {
62        if self.nested { NEST } else { 0 }
63    }
64
65    /// Message for Enter or Space on the row when its control does not use the key, or for a
66    /// click on the label; for rows that open something, such as a detail page.
67    #[must_use]
68    pub fn on_activate(mut self, message: Msg) -> Self {
69        self.on_activate = Some(message);
70        self
71    }
72
73    /// Where the label, the description and the control go in a row `width` cells wide whose
74    /// control is `control` cells wide.
75    ///
76    /// The label shares the first line with the control while it fits beside it. When it does
77    /// not, it takes the whole width, wrapping if it must, and the control moves to the line
78    /// under it. The description always wraps over the whole width, so a narrow screen shows all
79    /// of it instead of cutting it.
80    ///
81    /// `squeezed` says the control would be wider than the `control` cells it has beside the
82    /// label; it then goes under the label, where it has the whole row.
83    fn lines(&self, control: u16, squeezed: bool, width: u16) -> RowLines {
84        // The text column keeps one spare cell so the slide never reaches the control or the
85        // right edge.
86        let full = width.saturating_sub(LEAD + self.indent() + CONTROL_GAP + 1).max(1);
87        let beside = full.saturating_sub(control.saturating_add(CONTROL_GAP));
88        let (label, label_width, control_below) = if !squeezed && text::width(&self.label) <= beside {
89            (vec![self.label.clone()], beside, false)
90        } else {
91            (text::wrap(&self.label, full), full, control > 0)
92        };
93        let description = self.description.as_deref().map_or_else(Vec::new, |text| text::wrap(text, full));
94        RowLines { label, description, control_below, full, label_width }
95    }
96}
97
98/// Cells a control on a line of its own may take in a row `width` cells wide: everything after
99/// the pillar's lead and before the gap at the right edge.
100fn control_room(width: u16) -> u16 {
101    width.saturating_sub(LEAD + CONTROL_GAP)
102}
103
104/// The lines of one settings row, from [`SettingRow::lines`].
105struct RowLines {
106    label: Vec<String>,
107    description: Vec<String>,
108    /// Whether the control has its own line under the label.
109    control_below: bool,
110    /// Cells for text across the whole row.
111    full: u16,
112    /// Cells for each line of the label: beside the control, or the whole width.
113    label_width: u16,
114}
115
116impl RowLines {
117    fn label_rows(&self) -> u16 {
118        clamp_u16(i32::try_from(self.label.len()).unwrap_or(i32::MAX)).max(1)
119    }
120
121    /// The line the control sits on, counted from the top of the row.
122    fn control_row(&self) -> u16 {
123        if self.control_below { self.label_rows() } else { 0 }
124    }
125
126    /// The line the description starts on.
127    fn description_row(&self) -> u16 {
128        self.label_rows() + u16::from(self.control_below)
129    }
130
131    fn height(&self) -> u16 {
132        let description = clamp_u16(i32::try_from(self.description.len()).unwrap_or(i32::MAX));
133        self.description_row().saturating_add(description)
134    }
135}
136
137enum Entry<Msg> {
138    Heading(String),
139    /// A setting and the index of its control node.
140    Row(SettingRow<Msg>, usize),
141}
142
143/// Adds headings and rows to a [`SettingsList`] inside [`SettingsList::show`].
144pub struct SettingsRows<'a, Msg> {
145    entries: Vec<Entry<Msg>>,
146    controls: Vec<Node<Msg>>,
147    env: &'a crate::env::Env,
148    size: crate::geometry::Size,
149    idle: &'a crate::widget::IdleScope<Msg>,
150}
151
152impl<Msg: 'static> SettingsRows<'_, Msg> {
153    /// The environment the list is drawn in: the active theme, language and icons, for rows that
154    /// show them.
155    #[must_use]
156    pub fn env(&self) -> &crate::env::Env {
157        self.env
158    }
159
160    /// Adds a group heading.
161    pub fn heading(&mut self, title: impl Into<String>) {
162        self.entries.push(Entry::Heading(title.into()));
163    }
164
165    /// Adds `row` with the one control built by `control` (a switch, a select, a segmented control,
166    /// a value text). Give the control no focus handling of its own: the list takes focus as
167    /// one control and passes keys to the selected row.
168    pub fn row(&mut self, row: SettingRow<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) {
169        let mut children = Vec::new();
170        control(&mut View::new(&mut children, self.env, self.size, self.idle));
171        let index = self.controls.len();
172        self.controls.push(Node::new(Flex::new(Axis::Row, children), index));
173        self.entries.push(Entry::Row(row, index));
174    }
175}
176
177/// Settings, one per row: the label (and a faint description) on the left, its control
178/// anchored on the right.
179///
180/// Rows are bare until touched. The row under the pointer raises its surface with a soft
181/// pillar; the keyboard's row, while the list has focus, raises it further with a breathing
182/// pillar. A focused list raises only one row: moving the pointer onto a row makes it the
183/// keyboard's row. Only the label slides one cell right; the pillar and the control never move.
184/// The label column keeps one spare cell for that.
185///
186/// Nothing is cut on a narrow screen: a description wraps over the whole width of its row, and a
187/// label too long to share its line with the control takes the whole width, wrapping if it must,
188/// while the control moves to the line under it. So does a control that needs more than half the
189/// row, which then has the whole row. A row reports the height it wraps to.
190///
191/// The list takes focus as one control. ↑/↓ (and Home/End) move between enabled rows; every
192/// other key goes to the selected row's control, so Enter or Space toggles a switch or opens a
193/// select and ←/→ change a segmented control. Keys the control does not use activate the row
194/// when it has [`SettingRow::on_activate`]. The pointer works on controls directly; clicking a
195/// label selects its row. The application owns every value; the keyboard's row lives in the
196/// runtime.
197///
198/// One long list can hold a whole settings page inside a [`ScrollView`](crate::widgets::ScrollView):
199/// moving with the keys scrolls just enough to show the new row, and a click never scrolls, so
200/// the row stays under the pointer.
201///
202/// Style keys: `setting-row` (`bg`, `pillar`) with `hover`, `selected`, `focus`, `disabled`;
203/// `setting-label` (`fg`, `bold`) and `setting-description` (`fg`) with the same states;
204/// `settings-heading` (`fg`, `bold`).
205pub struct SettingsList<Msg> {
206    entries: Vec<Entry<Msg>>,
207    controls: Vec<Node<Msg>>,
208}
209
210#[derive(Debug, Default)]
211struct SettingsMemory {
212    selected: Option<usize>,
213    /// Where each control was painted, by control index.
214    controls: Vec<Rect>,
215    /// Where each row was painted, by control index.
216    rows: Vec<Rect>,
217    /// Where the pointer was in the last frame; moving it carries the keyboard's row.
218    pointer: Option<(i32, i32)>,
219    /// The keys moved the selection since the last frame, so a scroll view around a list
220    /// taller than itself shows the new row. A click or the pointer never scrolls: the row is
221    /// already under it.
222    reveal: bool,
223}
224
225impl<Msg: Clone + 'static> SettingsList<Msg> {
226    /// Adds a settings list with the headings and rows `build` adds to `ui`.
227    pub fn show<'v>(ui: &'v mut View<'_, Msg>, build: impl FnOnce(&mut SettingsRows<'_, Msg>)) -> NodeMut<'v, Msg> {
228        let (entries, controls) = {
229            let mut rows = SettingsRows {
230                entries: Vec::new(),
231                controls: Vec::new(),
232                env: ui.env(),
233                size: ui.size(),
234                idle: ui.idle_scope(),
235            };
236            build(&mut rows);
237            (rows.entries, rows.controls)
238        };
239        ui.add(Self { entries, controls }).fill_width()
240    }
241
242    fn row(&self, index: usize) -> Option<&SettingRow<Msg>> {
243        self.entries.iter().find_map(|entry| match entry {
244            Entry::Row(row, i) if *i == index => Some(row),
245            _ => None,
246        })
247    }
248
249    fn enabled(&self) -> Vec<usize> {
250        self.entries
251            .iter()
252            .filter_map(|entry| match entry {
253                Entry::Row(row, index) if !row.disabled => Some(*index),
254                _ => None,
255            })
256            .collect()
257    }
258
259    /// The keyboard's row: the remembered one when it is still enabled, else the first enabled.
260    fn current(&self, remembered: Option<usize>) -> Option<usize> {
261        let enabled = self.enabled();
262        remembered.filter(|index| enabled.contains(index)).or_else(|| enabled.first().copied())
263    }
264
265    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
266        match self.row(index).and_then(|row| row.on_activate.clone()) {
267            Some(message) => {
268                cx.flash();
269                cx.emit(message);
270                true
271            }
272            None => false,
273        }
274    }
275}
276
277impl<Msg: Clone + 'static> Widget<Msg> for SettingsList<Msg> {
278    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
279        let mut width = 0u16;
280        let mut height = 0u16;
281        for (position, entry) in self.entries.iter().enumerate() {
282            match entry {
283                Entry::Heading(title) => {
284                    height = height.saturating_add(1 + u16::from(position > 0));
285                    width = width.max(text::width(title).saturating_add(LEAD));
286                }
287                Entry::Row(row, index) => {
288                    let node = &self.controls[*index];
289                    let control = cx.measure_child(node, Size::new(available.width, 1)).width;
290                    let label = text::width(&row.label).max(row.description.as_deref().map_or(0, text::width));
291                    width = width.max(cells::sum([LEAD, row.indent(), label, 1, CONTROL_GAP * 2, control]));
292                    // Laid out as paint lays it out, so a narrow row reports the lines it wraps to.
293                    let beside = cx.measure_child(node, Size::new(available.width / 2, 1)).width;
294                    let whole = cx.measure_child(node, Size::new(control_room(available.width), 1)).width;
295                    height = height.saturating_add(row.lines(beside, whole > beside, available.width).height());
296                }
297            }
298        }
299        Size::new(width, height).min(available)
300    }
301
302    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
303        cx.register_hit(area);
304        let focused = cx.is_focused();
305        let pointer = cx.pointer_within();
306        let slide = cx.env().slide();
307        let enabled = self.enabled();
308        let selected = {
309            let memory = cx.memory::<SettingsMemory>();
310            // The pointer moves the one highlight: an enabled row it moves onto becomes the
311            // keyboard's row, so a focused list never raises two rows at once.
312            if pointer != memory.pointer {
313                memory.pointer = pointer;
314                let under = pointer.and_then(|(px, py)| memory.rows.iter().position(|rect| rect.contains(px, py)));
315                if let Some(index) = under.filter(|index| enabled.contains(index)) {
316                    memory.selected = Some(index);
317                }
318            }
319            let current = self.current(memory.selected);
320            memory.selected = current;
321            current.filter(|_| focused)
322        };
323        let mut controls = vec![Rect::default(); self.controls.len()];
324        let mut rows = vec![Rect::default(); self.controls.len()];
325        let mut y = area.y;
326        for (position, entry) in self.entries.iter().enumerate() {
327            match entry {
328                Entry::Heading(title) => {
329                    if position > 0 {
330                        y += 1;
331                    }
332                    let style = cx.style("settings-heading", None, &[]).text();
333                    let budget = area.width.saturating_sub(LEAD + 1);
334                    let shown = text::truncate(title, budget).into_owned();
335                    cx.text(area.x + i32::from(LEAD), y, &shown, style, budget);
336                    y += 1;
337                }
338                Entry::Row(row, index) => {
339                    let node = &self.controls[*index];
340                    // Beside the label a control has half the row; one that wants more goes on a
341                    // line of its own, where it has the whole row.
342                    let beside = cx.measure_child(node, Size::new(area.width / 2, 1)).width;
343                    let whole = cx.measure_child(node, Size::new(control_room(area.width), 1)).width;
344                    let lines = row.lines(beside, whole > beside, area.width);
345                    let control_width = if lines.control_below { whole } else { beside };
346                    let rect = Rect::new(area.x, y, area.width, lines.height());
347                    y += i32::from(lines.height());
348                    rows[*index] = rect;
349                    let mut states = Vec::new();
350                    if row.disabled {
351                        states.push(State::Disabled);
352                    } else {
353                        let pointed = pointer.is_some_and(|(px, py)| rect.contains(px, py));
354                        if pointed && (!focused || selected == Some(*index)) {
355                            states.push(State::Hover);
356                        }
357                        if selected == Some(*index) {
358                            states.extend([State::Selected, State::Focus]);
359                        }
360                    }
361                    let style = cx.style("setting-row", None, &states);
362                    if let Some(bg) = style.text().bg {
363                        cx.clear(rect, bg);
364                    }
365                    if let Some(color) = style.color("pillar") {
366                        for row_y in rect.y..rect.bottom() {
367                            cx.pillar(rect.x, row_y, color);
368                        }
369                    }
370
371                    let control_x = rect.right() - i32::from(CONTROL_GAP + control_width);
372                    let control = Rect::new(control_x, rect.y + i32::from(lines.control_row()), control_width, 1);
373                    controls[*index] = control;
374                    // The keys of the list's own row go to its control, so the control is painted
375                    // focused: a time or a duration keeps the part being typed only while focused.
376                    let keyboard_row = focused && selected == Some(*index) && !row.disabled;
377                    cx.paint_child_lending_focus(node, control, keyboard_row);
378
379                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
380                    let shift = u16::from(slide && raised);
381                    let x = rect.x + i32::from(LEAD + row.indent() + shift);
382                    let label_budget = lines.label_width;
383                    let label_style = cx.style("setting-label", None, &states).text();
384                    for (line, label) in (rect.y..).zip(&lines.label) {
385                        let label = text::truncate(label, label_budget).into_owned();
386                        cx.text(x, line, &label, CellStyle { bg: None, ..label_style }, label_budget);
387                    }
388                    let style = cx.style("setting-description", None, &states).text();
389                    let first = rect.y + i32::from(lines.description_row());
390                    for (line, description) in (first..).zip(&lines.description) {
391                        cx.text(x, line, description, CellStyle { bg: None, ..style }, lines.full);
392                    }
393                }
394            }
395        }
396        let memory = cx.memory::<SettingsMemory>();
397        let reveal = std::mem::take(&mut memory.reveal)
398            .then(|| memory.selected.and_then(|index| rows.get(index)))
399            .flatten()
400            .copied();
401        memory.controls = controls;
402        memory.rows = rows;
403        if let Some(row) = reveal {
404            cx.reveal(row);
405        }
406    }
407
408    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
409        let enabled = self.enabled();
410        if enabled.is_empty() {
411            return false;
412        }
413        let current = self.current(cx.memory::<SettingsMemory>().selected);
414        match event {
415            Event::Key(key) => {
416                let position = current.and_then(|index| enabled.iter().position(|i| *i == index)).unwrap_or(0);
417                let target = if key.is_plain(Key::Up) {
418                    Some(position.saturating_sub(1))
419                } else if key.is_plain(Key::Down) {
420                    Some((position + 1).min(enabled.len() - 1))
421                } else {
422                    None
423                };
424                if let Some(target) = target {
425                    let memory = cx.memory::<SettingsMemory>();
426                    memory.selected = Some(enabled[target]);
427                    memory.reveal = true;
428                    return true;
429                }
430                let Some(index) = current else {
431                    return false;
432                };
433                let rect = cx.memory::<SettingsMemory>().controls.get(index).copied().unwrap_or_default();
434                // The row holds one control inside its layout node; that control gets the key.
435                let used =
436                    self.controls[index].widget.children().iter().any(|control| cx.forward(control, rect, event));
437                if used {
438                    return true;
439                }
440                if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
441                    return self.activate(cx, index);
442                }
443                if key.is_plain(Key::Home) || key.is_plain(Key::End) {
444                    let target = if key.is_plain(Key::Home) { enabled[0] } else { enabled[enabled.len() - 1] };
445                    let memory = cx.memory::<SettingsMemory>();
446                    memory.selected = Some(target);
447                    memory.reveal = true;
448                    return true;
449                }
450                false
451            }
452            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
453                let rows = cx.memory::<SettingsMemory>().rows.clone();
454                let Some(index) = rows.iter().position(|rect| rect.contains(mouse.x, mouse.y)) else {
455                    return false;
456                };
457                if !enabled.contains(&index) {
458                    return true;
459                }
460                cx.memory::<SettingsMemory>().selected = Some(index);
461                cx.request_focus();
462                self.activate(cx, index);
463                true
464            }
465            _ => false,
466        }
467    }
468
469    fn focusable(&self) -> bool {
470        !self.enabled().is_empty()
471    }
472
473    fn children(&self) -> &[Node<Msg>] {
474        &self.controls
475    }
476
477    fn children_mut(&mut self) -> &mut [Node<Msg>] {
478        &mut self.controls
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::runtime::{App, Command, Harness};
486    use crate::widgets::{Segmented, Switch};
487
488    #[derive(Default)]
489    struct Prefs {
490        animations: bool,
491        density: usize,
492        opened: usize,
493        telemetry_locked: bool,
494    }
495
496    #[derive(Clone)]
497    enum Msg {
498        Animations(bool),
499        Density(usize),
500        Open,
501    }
502
503    impl App for Prefs {
504        type Msg = Msg;
505        fn update(&mut self, msg: Msg) -> Command<Msg> {
506            match msg {
507                Msg::Animations(on) => self.animations = on,
508                Msg::Density(index) => self.density = index,
509                Msg::Open => self.opened += 1,
510            }
511            Command::none()
512        }
513        fn view(&self, ui: &mut View<'_, Msg>) {
514            SettingsList::show(ui, |list| {
515                list.heading("APPEARANCE");
516                list.row(SettingRow::new("Animations").description("Motion in lists"), |ui| {
517                    ui.add(Switch::new(self.animations).on_toggle(Msg::Animations));
518                });
519                list.row(SettingRow::new("Density"), |ui| {
520                    ui.add(Segmented::new(["Cozy", "Compact"]).selected(self.density).on_select(Msg::Density));
521                });
522                list.heading("PRIVACY");
523                list.row(SettingRow::new("Telemetry").disabled(self.telemetry_locked), |ui| {
524                    ui.add(Switch::new(false).disabled(self.telemetry_locked));
525                });
526                list.row(SettingRow::new("Storage used by images and volumes").on_activate(Msg::Open), |ui| {
527                    ui.add(Text::new("2.4 GB"));
528                });
529            })
530            .id("settings");
531        }
532    }
533
534    use crate::widgets::Text;
535
536    /// Forty switches in one list, in a scroll view shorter than the list.
537    #[derive(Default)]
538    struct Long {
539        on: Vec<usize>,
540    }
541
542    impl App for Long {
543        type Msg = usize;
544        fn update(&mut self, index: usize) -> Command<usize> {
545            self.on.push(index);
546            Command::none()
547        }
548        fn view(&self, ui: &mut View<'_, usize>) {
549            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
550                SettingsList::show(ui, |list| {
551                    for n in 1..=40 {
552                        list.row(SettingRow::new(format!("Option {n}")), |ui| {
553                            ui.add(Switch::new(self.on.contains(&n)).on_toggle(move |_| n));
554                        });
555                    }
556                })
557                .fill_width();
558            })
559            .fill();
560        }
561    }
562
563    fn row_of(h: &Harness<Long>, n: usize) -> Option<usize> {
564        let label = n.to_string();
565        h.screen().lines().position(|line| {
566            let words: Vec<&str> = line.split_whitespace().collect();
567            words.windows(2).any(|pair| pair[0] == "Option" && pair[1] == label)
568        })
569    }
570
571    #[test]
572    fn a_list_taller_than_its_scroll_view_keeps_the_clicked_row_in_place_and_follows_the_keys() {
573        let mut h = Harness::new(Long::default(), 40, 20);
574        h.set_reduced_motion(true);
575        let third = row_of(&h, 3).unwrap_or_else(|| panic!("{}", h.screen()));
576        let (x, y) = h.find("Option 3").unwrap_or_else(|| panic!("{}", h.screen()));
577        h.click(x, y);
578        assert_eq!(row_of(&h, 3), Some(third), "a click does not scroll: {}", h.screen());
579        assert_eq!(row_of(&h, 1), Some(0), "the list stays at its top: {}", h.screen());
580        for _ in 3..25 {
581            h.press("down");
582        }
583        let screen = h.screen();
584        let last = screen
585            .lines()
586            .collect::<Vec<_>>()
587            .iter()
588            .rposition(|line| line.contains("Option"))
589            .unwrap_or_else(|| panic!("{screen}"));
590        assert_eq!(row_of(&h, 25), Some(last), "the selected row is the last one shown: {screen}");
591        h.press("up");
592        assert_eq!(row_of(&h, 25), Some(last), "going back up inside the view does not scroll: {}", h.screen());
593    }
594
595    #[test]
596    fn labels_left_controls_anchored_right_with_headings() {
597        let h = Harness::new(Prefs::default(), 40, 10);
598        assert_eq!(
599            h.screen(),
600            "  APPEARANCE\n  Animations                      \n  Motion in lists\n  Density            Cozy    Compact\n\n  PRIVACY\n  Telemetry\n  Storage used by images and volumes\n                                2.4 GB\n\n"
601                .lines()
602                .map(str::trim_end)
603                .collect::<Vec<_>>()
604                .join("\n")
605                + "\n"
606        );
607    }
608
609    /// Rows with long labels and descriptions, in English or German.
610    struct Wordy {
611        german: bool,
612    }
613
614    impl App for Wordy {
615        type Msg = bool;
616        fn update(&mut self, _: bool) -> Command<bool> {
617            Command::none()
618        }
619        fn view(&self, ui: &mut View<'_, bool>) {
620            let (motion, calm, hour, hour_note) = if self.german {
621                (
622                    "Bewegung",
623                    "Ebenen erscheinen sofort; nichts gleitet oder blendet über.",
624                    "Sitzungen vor dieser Stunde zählen zum Vortag",
625                    "Für Nachteulen, die nach Mitternacht arbeiten.",
626                )
627            } else {
628                (
629                    "Motion",
630                    "Layers appear at once; nothing slides or fades.",
631                    "Sessions before this hour count for the day before",
632                    "For night owls who work past midnight.",
633                )
634            };
635            SettingsList::show(ui, |list| {
636                list.row(SettingRow::new(motion).description(calm), |ui| {
637                    ui.add(Switch::new(true).on_toggle(|on| on));
638                });
639                list.row(SettingRow::new(hour).description(hour_note), |ui| {
640                    ui.add(Segmented::new(["0", "3", "5"]).selected(1).on_select(|_| true));
641                });
642            });
643        }
644    }
645
646    #[test]
647    fn at_forty_columns_descriptions_wrap_and_a_long_label_puts_its_control_below() {
648        for (german, code) in [(false, "en"), (true, "de")] {
649            let mut h = Harness::new(Wordy { german }, 40, 14);
650            h.set_locale(code);
651            let screen = h.screen();
652            assert!(!screen.contains('…'), "{code}: {screen}");
653            let lines: Vec<&str> = screen.lines().collect();
654            // The first label fits beside its switch; the description wraps under it.
655            assert!(lines[1].starts_with("  ") && lines[2].starts_with("  "), "{code}: {screen}");
656            let words: Vec<&str> = lines[1..3].iter().flat_map(|line| line.split_whitespace()).collect();
657            assert!(words.contains(&"nothing") || words.contains(&"nichts"), "{code}: {screen}");
658            // The long label takes its own lines and the control sits under it, at the right.
659            let hour = lines.iter().position(|line| line.contains("Sessions") || line.contains("Sitzungen"));
660            let hour = hour.unwrap_or_else(|| panic!("{code}: {screen}"));
661            let control =
662                lines.iter().position(|line| line.contains(" 0 ")).unwrap_or_else(|| panic!("{code}: {screen}"));
663            assert!(control > hour, "{code}: {screen}");
664            assert!(
665                lines[control].trim_start().starts_with('0'),
666                "the control has the line to itself: {code}: {screen}"
667            );
668            assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{code}: {screen}");
669        }
670    }
671
672    #[test]
673    fn below_forty_columns_every_row_degrades_to_label_then_control_then_description() {
674        for width in [36, 30, 24, 20] {
675            let h = Harness::new(Prefs::default(), width, 16);
676            let screen = h.screen();
677            let lines: Vec<&str> = screen.lines().collect();
678            // At 20 columns the two options of the segmented control cannot fit even on a line
679            // of their own; that cut is the control's, and every text of the list stays whole.
680            let cut: Vec<&&str> = lines.iter().filter(|line| line.contains('…')).collect();
681            assert!(cut.iter().all(|line| width == 20 && line.contains("Cozy")), "{width}: {screen}");
682            let storage = lines.iter().position(|line| line.contains("Storage")).unwrap_or_else(|| panic!("{screen}"));
683            let size = lines.iter().position(|line| line.contains("2.4 GB")).unwrap_or_else(|| panic!("{screen}"));
684            assert!(size > storage, "the value sits under its label: {width}: {screen}");
685            assert!(!lines[size].contains("Storage") && !lines[size].contains("volumes"), "{width}: {screen}");
686            assert!(screen.contains("Motion in lists") || screen.contains("Motion in"), "{width}: {screen}");
687            let density = lines.iter().position(|line| line.contains("Density")).unwrap_or_else(|| panic!("{screen}"));
688            let cozy = lines.iter().position(|line| line.contains("Cozy")).unwrap_or_else(|| panic!("{screen}"));
689            if cozy != density {
690                assert_eq!(cozy, density + 1, "the control right under its label: {width}: {screen}");
691                assert!(
692                    width == 20 || lines[cozy].contains("Compact"),
693                    "a control on its own line has the whole row: {width}: {screen}"
694                );
695            }
696        }
697        for width in [30, 24, 20] {
698            for (german, code) in [(false, "en"), (true, "de")] {
699                let mut h = Harness::new(Wordy { german }, width, 24);
700                h.set_locale(code);
701                let screen = h.screen();
702                assert!(!screen.contains('…'), "{width} {code}: {screen}");
703                assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{width} {code}: {screen}");
704                assert!(screen.contains(" 0    3    5") || screen.contains("0    3    5"), "{width} {code}: {screen}");
705            }
706        }
707    }
708
709    #[test]
710    fn a_row_reports_the_height_it_wraps_to() {
711        let row = SettingRow::<()>::new("Sessions before this hour count for the day before")
712            .description("For night owls who work past midnight.");
713        let wide = row.lines(9, false, 100);
714        assert_eq!((wide.height(), wide.control_row(), wide.description_row()), (2, 0, 1));
715        let narrow = row.lines(9, false, 40);
716        assert_eq!((narrow.label.len(), narrow.control_row(), narrow.description_row()), (2, 2, 3));
717        assert_eq!(narrow.height(), 5, "two label lines, the control, two description lines");
718        let squeezed = SettingRow::<()>::new("Density").lines(9, true, 40);
719        assert_eq!((squeezed.control_row(), squeezed.height()), (1, 2), "a squeezed control goes under its label");
720    }
721
722    #[test]
723    fn keyboard_moves_rows_and_drives_the_selected_control() {
724        let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 10);
725        h.press("tab");
726        let theme = h.env().theme();
727        assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
728        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
729        h.press("space");
730        assert!(h.app().animations);
731        h.press("down").press("right");
732        assert_eq!(h.app().density, 1);
733        h.press("down").press("enter");
734        assert_eq!(h.app().opened, 1, "the disabled row is skipped");
735        h.press("up");
736        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
737    }
738
739    #[test]
740    fn the_pointer_carries_the_keyboards_row() {
741        let mut h = Harness::new(Prefs::default(), 40, 10);
742        h.press("tab");
743        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
744        h.hover(6, 7);
745        let screen = h.screen();
746        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
747        assert_eq!(
748            raised,
749            ["▌  Storage used by images and volumes", "▌                               2.4 GB"],
750            "one raised row, both its lines:\n{screen}"
751        );
752        assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
753        assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
754        h.press("up");
755        let screen = h.screen();
756        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
757        assert_eq!(raised, ["▌  Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
758    }
759
760    #[test]
761    fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
762        let mut h = Harness::new(Prefs::default(), 40, 10);
763        let before = h.find("Cozy");
764        h.hover(4, 3);
765        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
766        assert_eq!(h.find("Cozy"), before);
767        h.hover(before.map_or(0, |(x, _)| x), 3);
768        assert!(
769            h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
770            "the row stays lit over its control"
771        );
772        h.click_text("Compact");
773        assert_eq!(h.app().density, 1);
774        h.click_text("Storage");
775        assert_eq!(h.app().opened, 1);
776    }
777
778    struct Nested;
779
780    impl App for Nested {
781        type Msg = ();
782        fn update(&mut self, (): ()) -> Command<()> {
783            Command::none()
784        }
785        fn view(&self, ui: &mut View<'_, ()>) {
786            SettingsList::show(ui, |list| {
787                list.row(SettingRow::new("Theme"), |ui| {
788                    ui.add(Segmented::new(["Dark", "Light"]).selected(0));
789                });
790                list.row(SettingRow::new("Everywhere").description("In every application").nested(true), |ui| {
791                    ui.add(Switch::new(true));
792                });
793            });
794        }
795    }
796
797    #[test]
798    fn a_nested_row_starts_two_cells_further_in_and_keeps_its_control_in_place() {
799        let mut h = Harness::new(Nested, 40, 4);
800        let (theme_x, _) = h.find("Theme").expect("parent");
801        let (nested_x, _) = h.find("Everywhere").expect("nested");
802        let (description_x, _) = h.find("In every").expect("description");
803        assert_eq!((nested_x, description_x), (theme_x + 2, theme_x + 2), "{}", h.screen());
804        h.resize(20, 6);
805        let (nested_x, _) = h.find("Everywhere").expect("nested, narrow");
806        assert_eq!(nested_x, theme_x + 2, "{}", h.screen());
807    }
808
809    /// A settings page with a time and a duration, as qfocus's settings have.
810    struct Clock {
811        turn: crate::date::TimeOfDay,
812        away: std::time::Duration,
813    }
814
815    #[derive(Clone)]
816    enum ClockMsg {
817        Turn(crate::date::TimeOfDay),
818        Away(std::time::Duration),
819    }
820
821    impl App for Clock {
822        type Msg = ClockMsg;
823        fn update(&mut self, msg: ClockMsg) -> Command<ClockMsg> {
824            match msg {
825                ClockMsg::Turn(time) => self.turn = time,
826                ClockMsg::Away(duration) => self.away = duration,
827            }
828            Command::none()
829        }
830        fn view(&self, ui: &mut View<'_, ClockMsg>) {
831            SettingsList::show(ui, |list| {
832                list.row(SettingRow::new("Day turns at"), |ui| {
833                    ui.add(crate::widgets::TimeInput::new(self.turn).on_change(ClockMsg::Turn));
834                });
835                list.row(SettingRow::new("Away after"), |ui| {
836                    ui.add(crate::widgets::DurationInput::new(self.away).on_change(ClockMsg::Away));
837                });
838            });
839        }
840    }
841
842    fn clock() -> Harness<Clock> {
843        let app = Clock { turn: crate::date::TimeOfDay::new(4, 0, 0), away: std::time::Duration::from_secs(15 * 60) };
844        let mut h = Harness::new(app, 60, 6);
845        h.set_reduced_motion(true).render();
846        h
847    }
848
849    #[test]
850    fn two_digits_typed_into_a_time_in_a_settings_row_make_one_value() {
851        let mut h = clock();
852        let (x, y) = h.find("04").unwrap_or_else(|| panic!("the hour is on screen:\n{}", h.screen()));
853        h.click(x, y);
854        h.type_text("12");
855        assert_eq!(
856            h.app().turn,
857            crate::date::TimeOfDay::new(12, 0, 0),
858            "two digits make twelve, not two:\n{}",
859            h.screen()
860        );
861        let (x, y) = h.find("00").unwrap_or_else(|| panic!("the minute is on screen:\n{}", h.screen()));
862        h.click(x, y);
863        h.type_text("05");
864        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(12, 5, 0), "the minute took the digits:\n{}", h.screen());
865    }
866
867    #[test]
868    fn the_keys_reach_the_minute_of_a_time_in_a_settings_row() {
869        let mut h = clock();
870        h.press("tab");
871        h.type_text("07");
872        h.press("right");
873        h.type_text("45");
874        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(7, 45, 0), "{}", h.screen());
875    }
876
877    #[test]
878    fn two_digits_typed_into_a_duration_in_a_settings_row_go_to_the_part_clicked() {
879        let mut h = clock();
880        let (x, y) = h.find("15").unwrap_or_else(|| panic!("the minutes are on screen:\n{}", h.screen()));
881        h.click(x, y);
882        h.type_text("05");
883        assert_eq!(h.app().away, std::time::Duration::from_secs(5 * 60), "the minutes, not the hours:\n{}", h.screen());
884    }
885}