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                    cx.paint_child_unfocusable(node, control);
375
376                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
377                    let shift = u16::from(slide && raised);
378                    let x = rect.x + i32::from(LEAD + row.indent() + shift);
379                    let label_budget = lines.label_width;
380                    let label_style = cx.style("setting-label", None, &states).text();
381                    for (line, label) in (rect.y..).zip(&lines.label) {
382                        let label = text::truncate(label, label_budget).into_owned();
383                        cx.text(x, line, &label, CellStyle { bg: None, ..label_style }, label_budget);
384                    }
385                    let style = cx.style("setting-description", None, &states).text();
386                    let first = rect.y + i32::from(lines.description_row());
387                    for (line, description) in (first..).zip(&lines.description) {
388                        cx.text(x, line, description, CellStyle { bg: None, ..style }, lines.full);
389                    }
390                }
391            }
392        }
393        let memory = cx.memory::<SettingsMemory>();
394        let reveal = std::mem::take(&mut memory.reveal)
395            .then(|| memory.selected.and_then(|index| rows.get(index)))
396            .flatten()
397            .copied();
398        memory.controls = controls;
399        memory.rows = rows;
400        if let Some(row) = reveal {
401            cx.reveal(row);
402        }
403    }
404
405    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
406        let enabled = self.enabled();
407        if enabled.is_empty() {
408            return false;
409        }
410        let current = self.current(cx.memory::<SettingsMemory>().selected);
411        match event {
412            Event::Key(key) => {
413                let position = current.and_then(|index| enabled.iter().position(|i| *i == index)).unwrap_or(0);
414                let target = if key.is_plain(Key::Up) {
415                    Some(position.saturating_sub(1))
416                } else if key.is_plain(Key::Down) {
417                    Some((position + 1).min(enabled.len() - 1))
418                } else {
419                    None
420                };
421                if let Some(target) = target {
422                    let memory = cx.memory::<SettingsMemory>();
423                    memory.selected = Some(enabled[target]);
424                    memory.reveal = true;
425                    return true;
426                }
427                let Some(index) = current else {
428                    return false;
429                };
430                let rect = cx.memory::<SettingsMemory>().controls.get(index).copied().unwrap_or_default();
431                // The row holds one control inside its layout node; that control gets the key.
432                let used =
433                    self.controls[index].widget.children().iter().any(|control| cx.forward(control, rect, event));
434                if used {
435                    return true;
436                }
437                if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
438                    return self.activate(cx, index);
439                }
440                if key.is_plain(Key::Home) || key.is_plain(Key::End) {
441                    let target = if key.is_plain(Key::Home) { enabled[0] } else { enabled[enabled.len() - 1] };
442                    let memory = cx.memory::<SettingsMemory>();
443                    memory.selected = Some(target);
444                    memory.reveal = true;
445                    return true;
446                }
447                false
448            }
449            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
450                let rows = cx.memory::<SettingsMemory>().rows.clone();
451                let Some(index) = rows.iter().position(|rect| rect.contains(mouse.x, mouse.y)) else {
452                    return false;
453                };
454                if !enabled.contains(&index) {
455                    return true;
456                }
457                cx.memory::<SettingsMemory>().selected = Some(index);
458                cx.request_focus();
459                self.activate(cx, index);
460                true
461            }
462            _ => false,
463        }
464    }
465
466    fn focusable(&self) -> bool {
467        !self.enabled().is_empty()
468    }
469
470    fn children(&self) -> &[Node<Msg>] {
471        &self.controls
472    }
473
474    fn children_mut(&mut self) -> &mut [Node<Msg>] {
475        &mut self.controls
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::runtime::{App, Command, Harness};
483    use crate::widgets::{Segmented, Switch};
484
485    #[derive(Default)]
486    struct Prefs {
487        animations: bool,
488        density: usize,
489        opened: usize,
490        telemetry_locked: bool,
491    }
492
493    #[derive(Clone)]
494    enum Msg {
495        Animations(bool),
496        Density(usize),
497        Open,
498    }
499
500    impl App for Prefs {
501        type Msg = Msg;
502        fn update(&mut self, msg: Msg) -> Command<Msg> {
503            match msg {
504                Msg::Animations(on) => self.animations = on,
505                Msg::Density(index) => self.density = index,
506                Msg::Open => self.opened += 1,
507            }
508            Command::none()
509        }
510        fn view(&self, ui: &mut View<'_, Msg>) {
511            SettingsList::show(ui, |list| {
512                list.heading("APPEARANCE");
513                list.row(SettingRow::new("Animations").description("Motion in lists"), |ui| {
514                    ui.add(Switch::new(self.animations).on_toggle(Msg::Animations));
515                });
516                list.row(SettingRow::new("Density"), |ui| {
517                    ui.add(Segmented::new(["Cozy", "Compact"]).selected(self.density).on_select(Msg::Density));
518                });
519                list.heading("PRIVACY");
520                list.row(SettingRow::new("Telemetry").disabled(self.telemetry_locked), |ui| {
521                    ui.add(Switch::new(false).disabled(self.telemetry_locked));
522                });
523                list.row(SettingRow::new("Storage used by images and volumes").on_activate(Msg::Open), |ui| {
524                    ui.add(Text::new("2.4 GB"));
525                });
526            })
527            .id("settings");
528        }
529    }
530
531    use crate::widgets::Text;
532
533    /// Forty switches in one list, in a scroll view shorter than the list.
534    #[derive(Default)]
535    struct Long {
536        on: Vec<usize>,
537    }
538
539    impl App for Long {
540        type Msg = usize;
541        fn update(&mut self, index: usize) -> Command<usize> {
542            self.on.push(index);
543            Command::none()
544        }
545        fn view(&self, ui: &mut View<'_, usize>) {
546            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
547                SettingsList::show(ui, |list| {
548                    for n in 1..=40 {
549                        list.row(SettingRow::new(format!("Option {n}")), |ui| {
550                            ui.add(Switch::new(self.on.contains(&n)).on_toggle(move |_| n));
551                        });
552                    }
553                })
554                .fill_width();
555            })
556            .fill();
557        }
558    }
559
560    fn row_of(h: &Harness<Long>, n: usize) -> Option<usize> {
561        let label = n.to_string();
562        h.screen().lines().position(|line| {
563            let words: Vec<&str> = line.split_whitespace().collect();
564            words.windows(2).any(|pair| pair[0] == "Option" && pair[1] == label)
565        })
566    }
567
568    #[test]
569    fn a_list_taller_than_its_scroll_view_keeps_the_clicked_row_in_place_and_follows_the_keys() {
570        let mut h = Harness::new(Long::default(), 40, 20);
571        h.set_reduced_motion(true);
572        let third = row_of(&h, 3).unwrap_or_else(|| panic!("{}", h.screen()));
573        let (x, y) = h.find("Option 3").unwrap_or_else(|| panic!("{}", h.screen()));
574        h.click(x, y);
575        assert_eq!(row_of(&h, 3), Some(third), "a click does not scroll: {}", h.screen());
576        assert_eq!(row_of(&h, 1), Some(0), "the list stays at its top: {}", h.screen());
577        for _ in 3..25 {
578            h.press("down");
579        }
580        let screen = h.screen();
581        let last = screen
582            .lines()
583            .collect::<Vec<_>>()
584            .iter()
585            .rposition(|line| line.contains("Option"))
586            .unwrap_or_else(|| panic!("{screen}"));
587        assert_eq!(row_of(&h, 25), Some(last), "the selected row is the last one shown: {screen}");
588        h.press("up");
589        assert_eq!(row_of(&h, 25), Some(last), "going back up inside the view does not scroll: {}", h.screen());
590    }
591
592    #[test]
593    fn labels_left_controls_anchored_right_with_headings() {
594        let h = Harness::new(Prefs::default(), 40, 10);
595        assert_eq!(
596            h.screen(),
597            "  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"
598                .lines()
599                .map(str::trim_end)
600                .collect::<Vec<_>>()
601                .join("\n")
602                + "\n"
603        );
604    }
605
606    /// Rows with long labels and descriptions, in English or German.
607    struct Wordy {
608        german: bool,
609    }
610
611    impl App for Wordy {
612        type Msg = bool;
613        fn update(&mut self, _: bool) -> Command<bool> {
614            Command::none()
615        }
616        fn view(&self, ui: &mut View<'_, bool>) {
617            let (motion, calm, hour, hour_note) = if self.german {
618                (
619                    "Bewegung",
620                    "Ebenen erscheinen sofort; nichts gleitet oder blendet über.",
621                    "Sitzungen vor dieser Stunde zählen zum Vortag",
622                    "Für Nachteulen, die nach Mitternacht arbeiten.",
623                )
624            } else {
625                (
626                    "Motion",
627                    "Layers appear at once; nothing slides or fades.",
628                    "Sessions before this hour count for the day before",
629                    "For night owls who work past midnight.",
630                )
631            };
632            SettingsList::show(ui, |list| {
633                list.row(SettingRow::new(motion).description(calm), |ui| {
634                    ui.add(Switch::new(true).on_toggle(|on| on));
635                });
636                list.row(SettingRow::new(hour).description(hour_note), |ui| {
637                    ui.add(Segmented::new(["0", "3", "5"]).selected(1).on_select(|_| true));
638                });
639            });
640        }
641    }
642
643    #[test]
644    fn at_forty_columns_descriptions_wrap_and_a_long_label_puts_its_control_below() {
645        for (german, code) in [(false, "en"), (true, "de")] {
646            let mut h = Harness::new(Wordy { german }, 40, 14);
647            h.set_locale(code);
648            let screen = h.screen();
649            assert!(!screen.contains('…'), "{code}: {screen}");
650            let lines: Vec<&str> = screen.lines().collect();
651            // The first label fits beside its switch; the description wraps under it.
652            assert!(lines[1].starts_with("  ") && lines[2].starts_with("  "), "{code}: {screen}");
653            let words: Vec<&str> = lines[1..3].iter().flat_map(|line| line.split_whitespace()).collect();
654            assert!(words.contains(&"nothing") || words.contains(&"nichts"), "{code}: {screen}");
655            // The long label takes its own lines and the control sits under it, at the right.
656            let hour = lines.iter().position(|line| line.contains("Sessions") || line.contains("Sitzungen"));
657            let hour = hour.unwrap_or_else(|| panic!("{code}: {screen}"));
658            let control =
659                lines.iter().position(|line| line.contains(" 0 ")).unwrap_or_else(|| panic!("{code}: {screen}"));
660            assert!(control > hour, "{code}: {screen}");
661            assert!(
662                lines[control].trim_start().starts_with('0'),
663                "the control has the line to itself: {code}: {screen}"
664            );
665            assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{code}: {screen}");
666        }
667    }
668
669    #[test]
670    fn below_forty_columns_every_row_degrades_to_label_then_control_then_description() {
671        for width in [36, 30, 24, 20] {
672            let h = Harness::new(Prefs::default(), width, 16);
673            let screen = h.screen();
674            let lines: Vec<&str> = screen.lines().collect();
675            // At 20 columns the two options of the segmented control cannot fit even on a line
676            // of their own; that cut is the control's, and every text of the list stays whole.
677            let cut: Vec<&&str> = lines.iter().filter(|line| line.contains('…')).collect();
678            assert!(cut.iter().all(|line| width == 20 && line.contains("Cozy")), "{width}: {screen}");
679            let storage = lines.iter().position(|line| line.contains("Storage")).unwrap_or_else(|| panic!("{screen}"));
680            let size = lines.iter().position(|line| line.contains("2.4 GB")).unwrap_or_else(|| panic!("{screen}"));
681            assert!(size > storage, "the value sits under its label: {width}: {screen}");
682            assert!(!lines[size].contains("Storage") && !lines[size].contains("volumes"), "{width}: {screen}");
683            assert!(screen.contains("Motion in lists") || screen.contains("Motion in"), "{width}: {screen}");
684            let density = lines.iter().position(|line| line.contains("Density")).unwrap_or_else(|| panic!("{screen}"));
685            let cozy = lines.iter().position(|line| line.contains("Cozy")).unwrap_or_else(|| panic!("{screen}"));
686            if cozy != density {
687                assert_eq!(cozy, density + 1, "the control right under its label: {width}: {screen}");
688                assert!(
689                    width == 20 || lines[cozy].contains("Compact"),
690                    "a control on its own line has the whole row: {width}: {screen}"
691                );
692            }
693        }
694        for width in [30, 24, 20] {
695            for (german, code) in [(false, "en"), (true, "de")] {
696                let mut h = Harness::new(Wordy { german }, width, 24);
697                h.set_locale(code);
698                let screen = h.screen();
699                assert!(!screen.contains('…'), "{width} {code}: {screen}");
700                assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{width} {code}: {screen}");
701                assert!(screen.contains(" 0    3    5") || screen.contains("0    3    5"), "{width} {code}: {screen}");
702            }
703        }
704    }
705
706    #[test]
707    fn a_row_reports_the_height_it_wraps_to() {
708        let row = SettingRow::<()>::new("Sessions before this hour count for the day before")
709            .description("For night owls who work past midnight.");
710        let wide = row.lines(9, false, 100);
711        assert_eq!((wide.height(), wide.control_row(), wide.description_row()), (2, 0, 1));
712        let narrow = row.lines(9, false, 40);
713        assert_eq!((narrow.label.len(), narrow.control_row(), narrow.description_row()), (2, 2, 3));
714        assert_eq!(narrow.height(), 5, "two label lines, the control, two description lines");
715        let squeezed = SettingRow::<()>::new("Density").lines(9, true, 40);
716        assert_eq!((squeezed.control_row(), squeezed.height()), (1, 2), "a squeezed control goes under its label");
717    }
718
719    #[test]
720    fn keyboard_moves_rows_and_drives_the_selected_control() {
721        let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 10);
722        h.press("tab");
723        let theme = h.env().theme();
724        assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
725        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
726        h.press("space");
727        assert!(h.app().animations);
728        h.press("down").press("right");
729        assert_eq!(h.app().density, 1);
730        h.press("down").press("enter");
731        assert_eq!(h.app().opened, 1, "the disabled row is skipped");
732        h.press("up");
733        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
734    }
735
736    #[test]
737    fn the_pointer_carries_the_keyboards_row() {
738        let mut h = Harness::new(Prefs::default(), 40, 10);
739        h.press("tab");
740        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
741        h.hover(6, 7);
742        let screen = h.screen();
743        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
744        assert_eq!(
745            raised,
746            ["▌  Storage used by images and volumes", "▌                               2.4 GB"],
747            "one raised row, both its lines:\n{screen}"
748        );
749        assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
750        assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
751        h.press("up");
752        let screen = h.screen();
753        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
754        assert_eq!(raised, ["▌  Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
755    }
756
757    #[test]
758    fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
759        let mut h = Harness::new(Prefs::default(), 40, 10);
760        let before = h.find("Cozy");
761        h.hover(4, 3);
762        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
763        assert_eq!(h.find("Cozy"), before);
764        h.hover(before.map_or(0, |(x, _)| x), 3);
765        assert!(
766            h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
767            "the row stays lit over its control"
768        );
769        h.click_text("Compact");
770        assert_eq!(h.app().density, 1);
771        h.click_text("Storage");
772        assert_eq!(h.app().opened, 1);
773    }
774
775    struct Nested;
776
777    impl App for Nested {
778        type Msg = ();
779        fn update(&mut self, (): ()) -> Command<()> {
780            Command::none()
781        }
782        fn view(&self, ui: &mut View<'_, ()>) {
783            SettingsList::show(ui, |list| {
784                list.row(SettingRow::new("Theme"), |ui| {
785                    ui.add(Segmented::new(["Dark", "Light"]).selected(0));
786                });
787                list.row(SettingRow::new("Everywhere").description("In every application").nested(true), |ui| {
788                    ui.add(Switch::new(true));
789                });
790            });
791        }
792    }
793
794    #[test]
795    fn a_nested_row_starts_two_cells_further_in_and_keeps_its_control_in_place() {
796        let mut h = Harness::new(Nested, 40, 4);
797        let (theme_x, _) = h.find("Theme").expect("parent");
798        let (nested_x, _) = h.find("Everywhere").expect("nested");
799        let (description_x, _) = h.find("In every").expect("description");
800        assert_eq!((nested_x, description_x), (theme_x + 2, theme_x + 2), "{}", h.screen());
801        h.resize(20, 6);
802        let (nested_x, _) = h.find("Everywhere").expect("nested, narrow");
803        assert_eq!(nested_x, theme_x + 2, "{}", h.screen());
804    }
805}