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    /// A list whose one row holds a name field.
537    #[derive(Default)]
538    struct Named {
539        name: String,
540    }
541
542    impl App for Named {
543        type Msg = String;
544        fn update(&mut self, name: String) -> Command<String> {
545            self.name = name;
546            Command::none()
547        }
548        fn view(&self, ui: &mut View<'_, String>) {
549            SettingsList::show(ui, |list| {
550                list.row(SettingRow::new("Name"), |ui| {
551                    ui.add(crate::widgets::TextInput::new(&self.name).on_change(|name| name))
552                        .width(crate::widget::Length::Cells(12));
553                });
554            });
555        }
556    }
557
558    #[test]
559    fn a_field_in_a_row_takes_every_space_of_a_burst() {
560        // The list has the focus and lends it to the row's field; spaces that arrive together are
561        // still typed, not taken for a held key.
562        let mut h = Harness::new(Named::default(), 40, 4);
563        h.press("tab");
564        let burst: Vec<crate::event::Event> = ["a", "space", "space", "b"]
565            .iter()
566            .map(|chord| crate::event::Event::Key(crate::event::KeyEvent::press(chord)))
567            .collect();
568        h.events(&burst);
569        assert_eq!(h.app().name, "a  b", "{}", h.screen());
570    }
571
572    /// Forty switches in one list, in a scroll view shorter than the list.
573    #[derive(Default)]
574    struct Long {
575        on: Vec<usize>,
576    }
577
578    impl App for Long {
579        type Msg = usize;
580        fn update(&mut self, index: usize) -> Command<usize> {
581            self.on.push(index);
582            Command::none()
583        }
584        fn view(&self, ui: &mut View<'_, usize>) {
585            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
586                SettingsList::show(ui, |list| {
587                    for n in 1..=40 {
588                        list.row(SettingRow::new(format!("Option {n}")), |ui| {
589                            ui.add(Switch::new(self.on.contains(&n)).on_toggle(move |_| n));
590                        });
591                    }
592                })
593                .fill_width();
594            })
595            .fill();
596        }
597    }
598
599    fn row_of(h: &Harness<Long>, n: usize) -> Option<usize> {
600        let label = n.to_string();
601        h.screen().lines().position(|line| {
602            let words: Vec<&str> = line.split_whitespace().collect();
603            words.windows(2).any(|pair| pair[0] == "Option" && pair[1] == label)
604        })
605    }
606
607    #[test]
608    fn a_list_taller_than_its_scroll_view_keeps_the_clicked_row_in_place_and_follows_the_keys() {
609        let mut h = Harness::new(Long::default(), 40, 20);
610        h.set_reduced_motion(true);
611        let third = row_of(&h, 3).unwrap_or_else(|| panic!("{}", h.screen()));
612        let (x, y) = h.find("Option 3").unwrap_or_else(|| panic!("{}", h.screen()));
613        h.click(x, y);
614        assert_eq!(row_of(&h, 3), Some(third), "a click does not scroll: {}", h.screen());
615        assert_eq!(row_of(&h, 1), Some(0), "the list stays at its top: {}", h.screen());
616        for _ in 3..25 {
617            h.press("down");
618        }
619        let screen = h.screen();
620        let last = screen
621            .lines()
622            .collect::<Vec<_>>()
623            .iter()
624            .rposition(|line| line.contains("Option"))
625            .unwrap_or_else(|| panic!("{screen}"));
626        assert_eq!(row_of(&h, 25), Some(last), "the selected row is the last one shown: {screen}");
627        h.press("up");
628        assert_eq!(row_of(&h, 25), Some(last), "going back up inside the view does not scroll: {}", h.screen());
629    }
630
631    #[test]
632    fn labels_left_controls_anchored_right_with_headings() {
633        let h = Harness::new(Prefs::default(), 40, 10);
634        assert_eq!(
635            h.screen(),
636            "  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"
637                .lines()
638                .map(str::trim_end)
639                .collect::<Vec<_>>()
640                .join("\n")
641                + "\n"
642        );
643    }
644
645    /// Rows with long labels and descriptions, in English or German.
646    struct Wordy {
647        german: bool,
648    }
649
650    impl App for Wordy {
651        type Msg = bool;
652        fn update(&mut self, _: bool) -> Command<bool> {
653            Command::none()
654        }
655        fn view(&self, ui: &mut View<'_, bool>) {
656            let (motion, calm, hour, hour_note) = if self.german {
657                (
658                    "Bewegung",
659                    "Ebenen erscheinen sofort; nichts gleitet oder blendet über.",
660                    "Sitzungen vor dieser Stunde zählen zum Vortag",
661                    "Für Nachteulen, die nach Mitternacht arbeiten.",
662                )
663            } else {
664                (
665                    "Motion",
666                    "Layers appear at once; nothing slides or fades.",
667                    "Sessions before this hour count for the day before",
668                    "For night owls who work past midnight.",
669                )
670            };
671            SettingsList::show(ui, |list| {
672                list.row(SettingRow::new(motion).description(calm), |ui| {
673                    ui.add(Switch::new(true).on_toggle(|on| on));
674                });
675                list.row(SettingRow::new(hour).description(hour_note), |ui| {
676                    ui.add(Segmented::new(["0", "3", "5"]).selected(1).on_select(|_| true));
677                });
678            });
679        }
680    }
681
682    #[test]
683    fn at_forty_columns_descriptions_wrap_and_a_long_label_puts_its_control_below() {
684        for (german, code) in [(false, "en"), (true, "de")] {
685            let mut h = Harness::new(Wordy { german }, 40, 14);
686            h.set_locale(code);
687            let screen = h.screen();
688            assert!(!screen.contains('…'), "{code}: {screen}");
689            let lines: Vec<&str> = screen.lines().collect();
690            // The first label fits beside its switch; the description wraps under it.
691            assert!(lines[1].starts_with("  ") && lines[2].starts_with("  "), "{code}: {screen}");
692            let words: Vec<&str> = lines[1..3].iter().flat_map(|line| line.split_whitespace()).collect();
693            assert!(words.contains(&"nothing") || words.contains(&"nichts"), "{code}: {screen}");
694            // The long label takes its own lines and the control sits under it, at the right.
695            let hour = lines.iter().position(|line| line.contains("Sessions") || line.contains("Sitzungen"));
696            let hour = hour.unwrap_or_else(|| panic!("{code}: {screen}"));
697            let control =
698                lines.iter().position(|line| line.contains(" 0 ")).unwrap_or_else(|| panic!("{code}: {screen}"));
699            assert!(control > hour, "{code}: {screen}");
700            assert!(
701                lines[control].trim_start().starts_with('0'),
702                "the control has the line to itself: {code}: {screen}"
703            );
704            assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{code}: {screen}");
705        }
706    }
707
708    #[test]
709    fn below_forty_columns_every_row_degrades_to_label_then_control_then_description() {
710        for width in [36, 30, 24, 20] {
711            let h = Harness::new(Prefs::default(), width, 16);
712            let screen = h.screen();
713            let lines: Vec<&str> = screen.lines().collect();
714            // At 20 columns the two options of the segmented control cannot fit even on a line
715            // of their own; that cut is the control's, and every text of the list stays whole.
716            let cut: Vec<&&str> = lines.iter().filter(|line| line.contains('…')).collect();
717            assert!(cut.iter().all(|line| width == 20 && line.contains("Cozy")), "{width}: {screen}");
718            let storage = lines.iter().position(|line| line.contains("Storage")).unwrap_or_else(|| panic!("{screen}"));
719            let size = lines.iter().position(|line| line.contains("2.4 GB")).unwrap_or_else(|| panic!("{screen}"));
720            assert!(size > storage, "the value sits under its label: {width}: {screen}");
721            assert!(!lines[size].contains("Storage") && !lines[size].contains("volumes"), "{width}: {screen}");
722            assert!(screen.contains("Motion in lists") || screen.contains("Motion in"), "{width}: {screen}");
723            let density = lines.iter().position(|line| line.contains("Density")).unwrap_or_else(|| panic!("{screen}"));
724            let cozy = lines.iter().position(|line| line.contains("Cozy")).unwrap_or_else(|| panic!("{screen}"));
725            if cozy != density {
726                assert_eq!(cozy, density + 1, "the control right under its label: {width}: {screen}");
727                assert!(
728                    width == 20 || lines[cozy].contains("Compact"),
729                    "a control on its own line has the whole row: {width}: {screen}"
730                );
731            }
732        }
733        for width in [30, 24, 20] {
734            for (german, code) in [(false, "en"), (true, "de")] {
735                let mut h = Harness::new(Wordy { german }, width, 24);
736                h.set_locale(code);
737                let screen = h.screen();
738                assert!(!screen.contains('…'), "{width} {code}: {screen}");
739                assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{width} {code}: {screen}");
740                assert!(screen.contains(" 0    3    5") || screen.contains("0    3    5"), "{width} {code}: {screen}");
741            }
742        }
743    }
744
745    #[test]
746    fn a_row_reports_the_height_it_wraps_to() {
747        let row = SettingRow::<()>::new("Sessions before this hour count for the day before")
748            .description("For night owls who work past midnight.");
749        let wide = row.lines(9, false, 100);
750        assert_eq!((wide.height(), wide.control_row(), wide.description_row()), (2, 0, 1));
751        let narrow = row.lines(9, false, 40);
752        assert_eq!((narrow.label.len(), narrow.control_row(), narrow.description_row()), (2, 2, 3));
753        assert_eq!(narrow.height(), 5, "two label lines, the control, two description lines");
754        let squeezed = SettingRow::<()>::new("Density").lines(9, true, 40);
755        assert_eq!((squeezed.control_row(), squeezed.height()), (1, 2), "a squeezed control goes under its label");
756    }
757
758    #[test]
759    fn keyboard_moves_rows_and_drives_the_selected_control() {
760        let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 10);
761        h.press("tab");
762        let theme = h.env().theme();
763        assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
764        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
765        h.press("space");
766        assert!(h.app().animations);
767        h.press("down").press("right");
768        assert_eq!(h.app().density, 1);
769        h.press("down").press("enter");
770        assert_eq!(h.app().opened, 1, "the disabled row is skipped");
771        h.press("up");
772        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
773    }
774
775    #[test]
776    fn the_pointer_carries_the_keyboards_row() {
777        let mut h = Harness::new(Prefs::default(), 40, 10);
778        h.press("tab");
779        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
780        h.hover(6, 7);
781        let screen = h.screen();
782        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
783        assert_eq!(
784            raised,
785            ["▌  Storage used by images and volumes", "▌                               2.4 GB"],
786            "one raised row, both its lines:\n{screen}"
787        );
788        assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
789        assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
790        h.press("up");
791        let screen = h.screen();
792        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
793        assert_eq!(raised, ["▌  Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
794    }
795
796    #[test]
797    fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
798        let mut h = Harness::new(Prefs::default(), 40, 10);
799        let before = h.find("Cozy");
800        h.hover(4, 3);
801        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
802        assert_eq!(h.find("Cozy"), before);
803        h.hover(before.map_or(0, |(x, _)| x), 3);
804        assert!(
805            h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
806            "the row stays lit over its control"
807        );
808        h.click_text("Compact");
809        assert_eq!(h.app().density, 1);
810        h.click_text("Storage");
811        assert_eq!(h.app().opened, 1);
812    }
813
814    struct Nested;
815
816    impl App for Nested {
817        type Msg = ();
818        fn update(&mut self, (): ()) -> Command<()> {
819            Command::none()
820        }
821        fn view(&self, ui: &mut View<'_, ()>) {
822            SettingsList::show(ui, |list| {
823                list.row(SettingRow::new("Theme"), |ui| {
824                    ui.add(Segmented::new(["Dark", "Light"]).selected(0));
825                });
826                list.row(SettingRow::new("Everywhere").description("In every application").nested(true), |ui| {
827                    ui.add(Switch::new(true));
828                });
829            });
830        }
831    }
832
833    #[test]
834    fn a_nested_row_starts_two_cells_further_in_and_keeps_its_control_in_place() {
835        let mut h = Harness::new(Nested, 40, 4);
836        let (theme_x, _) = h.find("Theme").expect("parent");
837        let (nested_x, _) = h.find("Everywhere").expect("nested");
838        let (description_x, _) = h.find("In every").expect("description");
839        assert_eq!((nested_x, description_x), (theme_x + 2, theme_x + 2), "{}", h.screen());
840        h.resize(20, 6);
841        let (nested_x, _) = h.find("Everywhere").expect("nested, narrow");
842        assert_eq!(nested_x, theme_x + 2, "{}", h.screen());
843    }
844
845    /// A settings page with a time and a duration, as qfocus's settings have.
846    struct Clock {
847        turn: crate::date::TimeOfDay,
848        away: std::time::Duration,
849    }
850
851    #[derive(Clone)]
852    enum ClockMsg {
853        Turn(crate::date::TimeOfDay),
854        Away(std::time::Duration),
855    }
856
857    impl App for Clock {
858        type Msg = ClockMsg;
859        fn update(&mut self, msg: ClockMsg) -> Command<ClockMsg> {
860            match msg {
861                ClockMsg::Turn(time) => self.turn = time,
862                ClockMsg::Away(duration) => self.away = duration,
863            }
864            Command::none()
865        }
866        fn view(&self, ui: &mut View<'_, ClockMsg>) {
867            SettingsList::show(ui, |list| {
868                list.row(SettingRow::new("Day turns at"), |ui| {
869                    ui.add(crate::widgets::TimeInput::new(self.turn).on_change(ClockMsg::Turn));
870                });
871                list.row(SettingRow::new("Away after"), |ui| {
872                    ui.add(crate::widgets::DurationInput::new(self.away).on_change(ClockMsg::Away));
873                });
874            });
875        }
876    }
877
878    fn clock() -> Harness<Clock> {
879        let app = Clock { turn: crate::date::TimeOfDay::new(4, 0, 0), away: std::time::Duration::from_secs(15 * 60) };
880        let mut h = Harness::new(app, 60, 6);
881        h.set_reduced_motion(true).render();
882        h
883    }
884
885    #[test]
886    fn two_digits_typed_into_a_time_in_a_settings_row_make_one_value() {
887        let mut h = clock();
888        let (x, y) = h.find("04").unwrap_or_else(|| panic!("the hour is on screen:\n{}", h.screen()));
889        h.click(x, y);
890        h.type_text("12");
891        assert_eq!(
892            h.app().turn,
893            crate::date::TimeOfDay::new(12, 0, 0),
894            "two digits make twelve, not two:\n{}",
895            h.screen()
896        );
897        let (x, y) = h.find("00").unwrap_or_else(|| panic!("the minute is on screen:\n{}", h.screen()));
898        h.click(x, y);
899        h.type_text("05");
900        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(12, 5, 0), "the minute took the digits:\n{}", h.screen());
901    }
902
903    #[test]
904    fn the_keys_reach_the_minute_of_a_time_in_a_settings_row() {
905        let mut h = clock();
906        h.press("tab");
907        h.type_text("07");
908        h.press("right");
909        h.type_text("45");
910        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(7, 45, 0), "{}", h.screen());
911    }
912
913    #[test]
914    fn two_digits_typed_into_a_duration_in_a_settings_row_go_to_the_part_clicked() {
915        let mut h = clock();
916        let (x, y) = h.find("15").unwrap_or_else(|| panic!("the minutes are on screen:\n{}", h.screen()));
917        h.click(x, y);
918        h.type_text("05");
919        assert_eq!(h.app().away, std::time::Duration::from_secs(5 * 60), "the minutes, not the hours:\n{}", h.screen());
920    }
921}