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 std::time::Duration;
4
5use crate::event::{Event, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::keymap::Key;
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{Axis, EventCx, Flex, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
12
13use super::cells;
14use super::placement::Placement;
15use super::row::LEAD;
16use super::tooltip;
17
18/// Cells between the label column and the control, and after the control.
19const CONTROL_GAP: u16 = 2;
20
21/// Cells a [nested](SettingRow::nested) row's text starts further in than its parent's.
22const NEST: u16 = 2;
23
24/// One setting of a [`SettingsList`]: a label, an optional description and the control added
25/// with [`SettingsRows::row`].
26pub struct SettingRow<Msg> {
27    label: String,
28    description: Option<String>,
29    hint: Option<String>,
30    disabled: bool,
31    nested: bool,
32    on_activate: Option<Msg>,
33}
34
35impl<Msg> SettingRow<Msg> {
36    /// A row labelled `label`.
37    #[must_use]
38    pub fn new(label: impl Into<String>) -> Self {
39        Self { label: label.into(), description: None, hint: None, disabled: false, nested: false, on_activate: None }
40    }
41
42    /// A faint note under the label, wrapped over as many lines as it needs.
43    #[must_use]
44    pub fn description(mut self, description: impl Into<String>) -> Self {
45        self.description = Some(description.into());
46        self
47    }
48
49    /// An explanation that takes no room of its own: it shows under the row, over the rows
50    /// below, while the row is the keyboard's row or while the pointer rests on it for the
51    /// theme's `motion.hover-delay`. It wraps within the label column, so it never covers a
52    /// control, and the row's control keeps every key. For what a person reads only when unsure,
53    /// where a [`description`](Self::description) would be always there.
54    #[must_use]
55    pub fn hint(mut self, hint: impl Into<String>) -> Self {
56        self.hint = Some(hint.into());
57        self
58    }
59
60    /// Greys the row out; the keyboard skips it. Disable its control as well.
61    #[must_use]
62    pub fn disabled(mut self, disabled: bool) -> Self {
63        self.disabled = disabled;
64        self
65    }
66
67    /// Marks the row as part of the row above it, such as a choice that qualifies that setting:
68    /// its label and description start two cells further in. The pillar and the control stay
69    /// where every row has them, and the keys reach it as any other row.
70    #[must_use]
71    pub fn nested(mut self, nested: bool) -> Self {
72        self.nested = nested;
73        self
74    }
75
76    /// Cells the row's text starts further in than a top-level row's.
77    fn indent(&self) -> u16 {
78        if self.nested { NEST } else { 0 }
79    }
80
81    /// Message for Enter or Space on the row when its control does not use the key, or for a
82    /// click on the label; for rows that open something, such as a detail page.
83    #[must_use]
84    pub fn on_activate(mut self, message: Msg) -> Self {
85        self.on_activate = Some(message);
86        self
87    }
88
89    /// Where the label, the description and the control go in a row `width` cells wide whose
90    /// control is `control` cells wide.
91    ///
92    /// The label shares the first line with the control while it fits beside it. When it does
93    /// not, it takes the whole width, wrapping if it must, and the control moves to the line
94    /// under it. The description always wraps over the whole width, so a narrow screen shows all
95    /// of it instead of cutting it.
96    ///
97    /// `squeezed` says the control would be wider than the `control` cells it has beside the
98    /// label; it then goes under the label, where it has the whole row.
99    fn lines(&self, control: u16, squeezed: bool, width: u16) -> RowLines {
100        // The text column keeps one spare cell so the slide never reaches the control or the
101        // right edge.
102        let full = width.saturating_sub(LEAD + self.indent() + CONTROL_GAP + 1).max(1);
103        let beside = full.saturating_sub(control.saturating_add(CONTROL_GAP));
104        let (label, label_width, control_below) = if !squeezed && text::width(&self.label) <= beside {
105            (vec![self.label.clone()], beside, false)
106        } else {
107            (text::wrap(&self.label, full), full, control > 0)
108        };
109        let description = self.description.as_deref().map_or_else(Vec::new, |text| text::wrap(text, full));
110        RowLines { label, description, control_below, full, label_width }
111    }
112}
113
114/// Cells a control on a line of its own may take in a row `width` cells wide: everything after
115/// the pillar's lead and before the gap at the right edge.
116fn control_room(width: u16) -> u16 {
117    width.saturating_sub(LEAD + CONTROL_GAP)
118}
119
120/// The lines of one settings row, from [`SettingRow::lines`].
121struct RowLines {
122    label: Vec<String>,
123    description: Vec<String>,
124    /// Whether the control has its own line under the label.
125    control_below: bool,
126    /// Cells for text across the whole row.
127    full: u16,
128    /// Cells for each line of the label: beside the control, or the whole width.
129    label_width: u16,
130}
131
132impl RowLines {
133    fn label_rows(&self) -> u16 {
134        clamp_u16(i32::try_from(self.label.len()).unwrap_or(i32::MAX)).max(1)
135    }
136
137    /// The line the control sits on, counted from the top of the row.
138    fn control_row(&self) -> u16 {
139        if self.control_below { self.label_rows() } else { 0 }
140    }
141
142    /// The line the description starts on.
143    fn description_row(&self) -> u16 {
144        self.label_rows() + u16::from(self.control_below)
145    }
146
147    fn height(&self) -> u16 {
148        let description = clamp_u16(i32::try_from(self.description.len()).unwrap_or(i32::MAX));
149        self.description_row().saturating_add(description)
150    }
151}
152
153enum Entry<Msg> {
154    Heading(String),
155    /// A setting and the index of its control node.
156    Row(SettingRow<Msg>, usize),
157}
158
159/// Adds headings and rows to a [`SettingsList`] inside [`SettingsList::show`].
160pub struct SettingsRows<'a, Msg> {
161    entries: Vec<Entry<Msg>>,
162    wrap: bool,
163    controls: Vec<Node<Msg>>,
164    env: &'a crate::env::Env,
165    size: crate::geometry::Size,
166    idle: &'a crate::widget::IdleScope<Msg>,
167}
168
169impl<Msg: 'static> SettingsRows<'_, Msg> {
170    /// The environment the list is drawn in: the active theme, language and icons, for rows that
171    /// show them.
172    #[must_use]
173    pub fn env(&self) -> &crate::env::Env {
174        self.env
175    }
176
177    /// Lets ↓ on the last enabled row go on to the first and ↑ on the first go to the last, as a
178    /// menu does. Home and End still stop at the ends. Off by default.
179    pub fn wrap(&mut self, wrap: bool) {
180        self.wrap = wrap;
181    }
182
183    /// Adds a group heading.
184    pub fn heading(&mut self, title: impl Into<String>) {
185        self.entries.push(Entry::Heading(title.into()));
186    }
187
188    /// Adds `row` with the one control built by `control` (a switch, a select, a segmented control,
189    /// a value text). Give the control no focus handling of its own: the list takes focus as
190    /// one control and passes keys to the selected row.
191    pub fn row(&mut self, row: SettingRow<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) {
192        let mut children = Vec::new();
193        control(&mut View::new(&mut children, self.env, self.size, self.idle));
194        let index = self.controls.len();
195        self.controls.push(Node::new(Flex::new(Axis::Row, children), index));
196        self.entries.push(Entry::Row(row, index));
197    }
198}
199
200/// Settings, one per row: the label (and a faint description) on the left, its control
201/// anchored on the right.
202///
203/// Rows are bare until touched. The row under the pointer raises its surface with a soft
204/// pillar; the keyboard's row, while the list has focus, raises it further with a breathing
205/// pillar. A focused list raises only one row: moving the pointer onto a row makes it the
206/// keyboard's row. Only the label slides one cell right; the pillar and the control never move.
207/// The label column keeps one spare cell for that.
208///
209/// Nothing is cut on a narrow screen: a description wraps over the whole width of its row, and a
210/// label too long to share its line with the control takes the whole width, wrapping if it must,
211/// while the control moves to the line under it. So does a control that needs more than half the
212/// row, which then has the whole row. A row reports the height it wraps to.
213///
214/// The list takes focus as one control. ↑/↓ (and Home/End) move between enabled rows, going
215/// round the ends with [`SettingsRows::wrap`]; every
216/// other key goes to the selected row's control, so Enter or Space toggles a switch or opens a
217/// select and ←/→ change a segmented control. Keys the control does not use activate the row
218/// when it has [`SettingRow::on_activate`]. The pointer works on controls directly; clicking a
219/// label selects its row. The application owns every value; the keyboard's row lives in the
220/// runtime.
221///
222/// One long list can hold a whole settings page inside a [`ScrollView`](crate::widgets::ScrollView):
223/// moving with the keys scrolls just enough to show the new row, and a click never scrolls, so
224/// the row stays under the pointer.
225///
226/// A row's [`hint`](SettingRow::hint) shows under it at once while it is the keyboard's row, and
227/// after the hover delay while the pointer rests on it.
228///
229/// Style keys: `setting-row` (`bg`, `pillar`) with `hover`, `selected`, `focus`, `disabled`;
230/// `setting-label` (`fg`, `bold`) and `setting-description` (`fg`) with the same states;
231/// `settings-heading` (`fg`, `bold`); `tooltip` for hints.
232pub struct SettingsList<Msg> {
233    entries: Vec<Entry<Msg>>,
234    controls: Vec<Node<Msg>>,
235    wrap: bool,
236}
237
238/// The hint on screen: whose it is, where it goes and since when it shows.
239#[derive(Debug, Clone, Copy)]
240struct ShownHint {
241    row: usize,
242    /// The row's label column, which the hint hangs from.
243    anchor: Rect,
244    /// Cells the hint may take before it would reach a control.
245    width: u16,
246    since: Duration,
247}
248
249#[derive(Debug, Default)]
250struct SettingsMemory {
251    selected: Option<usize>,
252    /// Where each control was painted, by control index.
253    controls: Vec<Rect>,
254    /// Where each row was painted, by control index.
255    rows: Vec<Rect>,
256    /// Where the pointer was in the last frame; moving it carries the keyboard's row.
257    pointer: Option<(i32, i32)>,
258    /// The keys moved the selection since the last frame, so a scroll view around a list
259    /// taller than itself shows the new row. A click or the pointer never scrolls: the row is
260    /// already under it.
261    reveal: bool,
262    /// The keys moved the keyboard's row since the pointer last did, so its hint shows at once
263    /// even in a list that was clicked into.
264    keyed: bool,
265    /// The row the pointer rests on and since when.
266    rested: Option<(usize, Duration)>,
267    hint: Option<ShownHint>,
268}
269
270impl<Msg: Clone + 'static> SettingsList<Msg> {
271    /// Adds a settings list with the headings and rows `build` adds to `ui`.
272    pub fn show<'v>(ui: &'v mut View<'_, Msg>, build: impl FnOnce(&mut SettingsRows<'_, Msg>)) -> NodeMut<'v, Msg> {
273        let (entries, controls, wrap) = {
274            let mut rows = SettingsRows {
275                entries: Vec::new(),
276                wrap: false,
277                controls: Vec::new(),
278                env: ui.env(),
279                size: ui.size(),
280                idle: ui.idle_scope(),
281            };
282            build(&mut rows);
283            (rows.entries, rows.controls, rows.wrap)
284        };
285        ui.add(Self { entries, controls, wrap }).fill_width()
286    }
287
288    fn row(&self, index: usize) -> Option<&SettingRow<Msg>> {
289        self.entries.iter().find_map(|entry| match entry {
290            Entry::Row(row, i) if *i == index => Some(row),
291            _ => None,
292        })
293    }
294
295    fn enabled(&self) -> Vec<usize> {
296        self.entries
297            .iter()
298            .filter_map(|entry| match entry {
299                Entry::Row(row, index) if !row.disabled => Some(*index),
300                _ => None,
301            })
302            .collect()
303    }
304
305    /// The keyboard's row: the remembered one when it is still enabled, else the first enabled.
306    fn current(&self, remembered: Option<usize>) -> Option<usize> {
307        let enabled = self.enabled();
308        remembered.filter(|index| enabled.contains(index)).or_else(|| enabled.first().copied())
309    }
310
311    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
312        match self.row(index).and_then(|row| row.on_activate.clone()) {
313            Some(message) => {
314                cx.flash();
315                cx.emit(message);
316                true
317            }
318            None => false,
319        }
320    }
321
322    /// Decides which row's hint shows, if any, from the rows and controls just painted: the row
323    /// the pointer has rested on for the hover delay, else the keyboard's row while the keyboard
324    /// is what moved there.
325    fn place_hint(&self, cx: &mut PaintCx<'_>, area: Rect, rows: &[Rect], controls: &[Rect], selected: Option<usize>) {
326        let now = cx.now();
327        let delay = cx.env().theme().motion().hover_delay;
328        let visible = cx.is_focus_visible();
329        let pointer = cx.pointer_within();
330        let has_hint = |index: &usize| self.row(*index).is_some_and(|row| row.hint.is_some() && !row.disabled);
331        let under = pointer.and_then(|(px, py)| rows.iter().position(|rect| rect.contains(px, py))).filter(has_hint);
332        let memory = cx.memory::<SettingsMemory>();
333        memory.rested = under.map(|row| match memory.rested {
334            Some((rested, since)) if rested == row => (row, since),
335            _ => (row, now),
336        });
337        let due = memory.rested.map(|(_, since)| since + delay);
338        let pointed = memory.rested.filter(|_| due.is_some_and(|due| now >= due)).map(|(row, _)| row);
339        let keyboard = selected.filter(has_hint).filter(|_| visible || memory.keyed);
340        let Some(row) = pointed.or(keyboard) else {
341            memory.hint = None;
342            if let Some(due) = due {
343                cx.request_frame_in(due.saturating_sub(now));
344            }
345            return;
346        };
347        let since = memory.hint.filter(|hint| hint.row == row).map_or(now, |hint| hint.since);
348        let rect = rows[row];
349        let indent = self.row(row).map_or(0, SettingRow::indent);
350        let x = rect.x + i32::from(LEAD + indent);
351        // Hanging under the label column, the hint stops short of every control on screen.
352        let reach = controls.iter().filter(|control| !control.is_empty()).map(|control| control.x).min();
353        let end = reach.map_or(area.right(), |reach| reach - i32::from(CONTROL_GAP)).max(x + 1);
354        let width = u16::try_from(end - x).unwrap_or(u16::MAX);
355        let anchor = Rect::new(x, rect.y, width, rect.height);
356        memory.hint = Some(ShownHint { row, anchor, width, since });
357        cx.request_overlay(anchor);
358    }
359}
360
361/// Offers `event` to `node` the way focus would reach it: the innermost widget first, then each
362/// wrapper around it. True when one of them used it.
363fn offer<Msg: 'static>(cx: &mut EventCx<'_, Msg>, node: &Node<Msg>, rect: Rect, event: &Event) -> bool {
364    node.widget.children().iter().any(|child| offer(cx, child, rect, event)) || cx.forward(node, rect, event)
365}
366
367impl<Msg: Clone + 'static> Widget<Msg> for SettingsList<Msg> {
368    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
369        let mut width = 0u16;
370        let mut height = 0u16;
371        for (position, entry) in self.entries.iter().enumerate() {
372            match entry {
373                Entry::Heading(title) => {
374                    height = height.saturating_add(1 + u16::from(position > 0));
375                    width = width.max(text::width(title).saturating_add(LEAD));
376                }
377                Entry::Row(row, index) => {
378                    let node = &self.controls[*index];
379                    let control = cx.measure_child(node, Size::new(available.width, 1)).width;
380                    let label = text::width(&row.label).max(row.description.as_deref().map_or(0, text::width));
381                    width = width.max(cells::sum([LEAD, row.indent(), label, 1, CONTROL_GAP * 2, control]));
382                    // Laid out as paint lays it out, so a narrow row reports the lines it wraps to.
383                    let beside = cx.measure_child(node, Size::new(available.width / 2, 1)).width;
384                    let whole = cx.measure_child(node, Size::new(control_room(available.width), 1)).width;
385                    height = height.saturating_add(row.lines(beside, whole > beside, available.width).height());
386                }
387            }
388        }
389        Size::new(width, height).min(available)
390    }
391
392    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
393        cx.register_hit(area);
394        let focused = cx.is_focused();
395        let pointer = cx.pointer_within();
396        let slide = cx.env().slide();
397        let enabled = self.enabled();
398        let selected = {
399            let memory = cx.memory::<SettingsMemory>();
400            // The pointer moves the one highlight: an enabled row it moves onto becomes the
401            // keyboard's row, so a focused list never raises two rows at once.
402            if pointer != memory.pointer {
403                memory.pointer = pointer;
404                let under = pointer.and_then(|(px, py)| memory.rows.iter().position(|rect| rect.contains(px, py)));
405                if let Some(index) = under.filter(|index| enabled.contains(index)) {
406                    memory.selected = Some(index);
407                    memory.keyed = false;
408                }
409            }
410            let current = self.current(memory.selected);
411            memory.selected = current;
412            current.filter(|_| focused)
413        };
414        let mut controls = vec![Rect::default(); self.controls.len()];
415        let mut rows = vec![Rect::default(); self.controls.len()];
416        let mut y = area.y;
417        for (position, entry) in self.entries.iter().enumerate() {
418            match entry {
419                Entry::Heading(title) => {
420                    if position > 0 {
421                        y += 1;
422                    }
423                    let style = cx.style("settings-heading", None, &[]).text();
424                    let budget = area.width.saturating_sub(LEAD + 1);
425                    let shown = text::truncate(title, budget).into_owned();
426                    cx.text(area.x + i32::from(LEAD), y, &shown, style, budget);
427                    y += 1;
428                }
429                Entry::Row(row, index) => {
430                    let node = &self.controls[*index];
431                    // Beside the label a control has half the row; one that wants more goes on a
432                    // line of its own, where it has the whole row.
433                    let beside = cx.measure_child(node, Size::new(area.width / 2, 1)).width;
434                    let whole = cx.measure_child(node, Size::new(control_room(area.width), 1)).width;
435                    let lines = row.lines(beside, whole > beside, area.width);
436                    let control_width = if lines.control_below { whole } else { beside };
437                    let rect = Rect::new(area.x, y, area.width, lines.height());
438                    y += i32::from(lines.height());
439                    rows[*index] = rect;
440                    let mut states = Vec::new();
441                    if row.disabled {
442                        states.push(State::Disabled);
443                    } else {
444                        let pointed = pointer.is_some_and(|(px, py)| rect.contains(px, py));
445                        if pointed && (!focused || selected == Some(*index)) {
446                            states.push(State::Hover);
447                        }
448                        if selected == Some(*index) {
449                            states.extend([State::Selected, State::Focus]);
450                        }
451                    }
452                    let style = cx.style("setting-row", None, &states);
453                    if let Some(bg) = style.text().bg {
454                        cx.clear(rect, bg);
455                    }
456                    if let Some(color) = style.color("pillar") {
457                        for row_y in rect.y..rect.bottom() {
458                            cx.pillar(rect.x, row_y, color);
459                        }
460                    }
461
462                    let control_x = rect.right() - i32::from(CONTROL_GAP + control_width);
463                    let control = Rect::new(control_x, rect.y + i32::from(lines.control_row()), control_width, 1);
464                    controls[*index] = control;
465                    // The keys of the list's own row go to its control, so the control is painted
466                    // focused: a time or a duration keeps the part being typed only while focused.
467                    let keyboard_row = focused && selected == Some(*index) && !row.disabled;
468                    cx.paint_child_lending_focus(node, control, keyboard_row);
469
470                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
471                    let shift = u16::from(slide && raised);
472                    let x = rect.x + i32::from(LEAD + row.indent() + shift);
473                    let label_budget = lines.label_width;
474                    let label_style = cx.style("setting-label", None, &states).text();
475                    for (line, label) in (rect.y..).zip(&lines.label) {
476                        let label = text::truncate(label, label_budget).into_owned();
477                        cx.text(x, line, &label, CellStyle { bg: None, ..label_style }, label_budget);
478                    }
479                    let style = cx.style("setting-description", None, &states).text();
480                    let first = rect.y + i32::from(lines.description_row());
481                    for (line, description) in (first..).zip(&lines.description) {
482                        cx.text(x, line, description, CellStyle { bg: None, ..style }, lines.full);
483                    }
484                }
485            }
486        }
487        self.place_hint(cx, area, &rows, &controls, selected);
488        let memory = cx.memory::<SettingsMemory>();
489        let keyboard_row = memory.selected.and_then(|index| rows.get(index)).copied();
490        let reveal = std::mem::take(&mut memory.reveal).then_some(keyboard_row).flatten();
491        memory.controls = controls;
492        memory.rows = rows;
493        if let Some(row) = keyboard_row {
494            // A long list taking the focus in a scroll view shows the row the keyboard is on, not
495            // its own top.
496            cx.focus_spot(row);
497        }
498        if let Some(row) = reveal {
499            cx.reveal(row);
500        }
501    }
502
503    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
504        let enabled = self.enabled();
505        if enabled.is_empty() {
506            return false;
507        }
508        let current = self.current(cx.memory::<SettingsMemory>().selected);
509        match event {
510            Event::Key(key) => {
511                let position = current.and_then(|index| enabled.iter().position(|i| *i == index)).unwrap_or(0);
512                let last = enabled.len() - 1;
513                let target = if key.is_plain(Key::Up) {
514                    Some(if self.wrap && position == 0 { last } else { position.saturating_sub(1) })
515                } else if key.is_plain(Key::Down) {
516                    Some(if self.wrap && position >= last { 0 } else { (position + 1).min(last) })
517                } else {
518                    None
519                };
520                if let Some(target) = target {
521                    let memory = cx.memory::<SettingsMemory>();
522                    memory.selected = Some(enabled[target]);
523                    memory.reveal = true;
524                    memory.keyed = true;
525                    return true;
526                }
527                let Some(index) = current else {
528                    return false;
529                };
530                let rect = cx.memory::<SettingsMemory>().controls.get(index).copied().unwrap_or_default();
531                // The row holds one control inside its layout node; that control gets the key, even
532                // when it sits inside a wrapper such as a tooltip.
533                let used = self.controls[index].widget.children().iter().any(|control| offer(cx, control, rect, event));
534                if used {
535                    return true;
536                }
537                if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
538                    return self.activate(cx, index);
539                }
540                if key.is_plain(Key::Home) || key.is_plain(Key::End) {
541                    let target = if key.is_plain(Key::Home) { enabled[0] } else { enabled[enabled.len() - 1] };
542                    let memory = cx.memory::<SettingsMemory>();
543                    memory.selected = Some(target);
544                    memory.reveal = true;
545                    memory.keyed = true;
546                    return true;
547                }
548                false
549            }
550            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
551                let rows = cx.memory::<SettingsMemory>().rows.clone();
552                let Some(index) = rows.iter().position(|rect| rect.contains(mouse.x, mouse.y)) else {
553                    return false;
554                };
555                if !enabled.contains(&index) {
556                    return true;
557                }
558                cx.memory::<SettingsMemory>().selected = Some(index);
559                cx.request_focus();
560                self.activate(cx, index);
561                true
562            }
563            _ => false,
564        }
565    }
566
567    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
568        let Some(shown) = cx.memory::<SettingsMemory>().hint else { return };
569        if let Some(hint) = self.row(shown.row).and_then(|row| row.hint.as_deref()) {
570            tooltip::paint_wrapped_tip(cx, shown.anchor, hint, shown.width, Placement::Below, shown.since);
571        }
572    }
573
574    fn focusable(&self) -> bool {
575        !self.enabled().is_empty()
576    }
577
578    fn children(&self) -> &[Node<Msg>] {
579        &self.controls
580    }
581
582    fn children_mut(&mut self) -> &mut [Node<Msg>] {
583        &mut self.controls
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::runtime::{App, Command, Harness};
591    use crate::widgets::{Segmented, Switch};
592
593    #[derive(Default)]
594    struct Prefs {
595        animations: bool,
596        density: usize,
597        opened: usize,
598        telemetry_locked: bool,
599    }
600
601    #[derive(Clone)]
602    enum Msg {
603        Animations(bool),
604        Density(usize),
605        Open,
606    }
607
608    impl App for Prefs {
609        type Msg = Msg;
610        fn update(&mut self, msg: Msg) -> Command<Msg> {
611            match msg {
612                Msg::Animations(on) => self.animations = on,
613                Msg::Density(index) => self.density = index,
614                Msg::Open => self.opened += 1,
615            }
616            Command::none()
617        }
618        fn view(&self, ui: &mut View<'_, Msg>) {
619            SettingsList::show(ui, |list| {
620                list.heading("APPEARANCE");
621                list.row(SettingRow::new("Animations").description("Motion in lists"), |ui| {
622                    ui.add(Switch::new(self.animations).on_toggle(Msg::Animations));
623                });
624                list.row(SettingRow::new("Density"), |ui| {
625                    ui.add(Segmented::new(["Cozy", "Compact"]).selected(self.density).on_select(Msg::Density));
626                });
627                list.heading("PRIVACY");
628                list.row(SettingRow::new("Telemetry").disabled(self.telemetry_locked), |ui| {
629                    ui.add(Switch::new(false).disabled(self.telemetry_locked));
630                });
631                list.row(SettingRow::new("Storage used by images and volumes").on_activate(Msg::Open), |ui| {
632                    ui.add(Text::new("2.4 GB"));
633                });
634            })
635            .id("settings");
636        }
637    }
638
639    use crate::widgets::Text;
640
641    /// A list whose one row holds a name field.
642    #[derive(Default)]
643    struct Named {
644        name: String,
645    }
646
647    impl App for Named {
648        type Msg = String;
649        fn update(&mut self, name: String) -> Command<String> {
650            self.name = name;
651            Command::none()
652        }
653        fn view(&self, ui: &mut View<'_, String>) {
654            SettingsList::show(ui, |list| {
655                list.row(SettingRow::new("Name"), |ui| {
656                    ui.add(crate::widgets::TextInput::new(&self.name).on_change(|name| name))
657                        .width(crate::widget::Length::Cells(12));
658                });
659            });
660        }
661    }
662
663    #[test]
664    fn a_field_in_a_row_takes_every_space_of_a_burst() {
665        // The list has the focus and lends it to the row's field; spaces that arrive together are
666        // still typed, not taken for a held key.
667        let mut h = Harness::new(Named::default(), 40, 4);
668        h.press("tab");
669        let burst: Vec<crate::event::Event> = ["a", "space", "space", "b"]
670            .iter()
671            .map(|chord| crate::event::Event::Key(crate::event::KeyEvent::press(chord)))
672            .collect();
673        h.events(&burst);
674        assert_eq!(h.app().name, "a  b", "{}", h.screen());
675    }
676
677    /// Forty switches in one list, in a scroll view shorter than the list.
678    #[derive(Default)]
679    struct Long {
680        on: Vec<usize>,
681    }
682
683    impl App for Long {
684        type Msg = usize;
685        fn update(&mut self, index: usize) -> Command<usize> {
686            self.on.push(index);
687            Command::none()
688        }
689        fn view(&self, ui: &mut View<'_, usize>) {
690            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
691                SettingsList::show(ui, |list| {
692                    for n in 1..=40 {
693                        list.row(SettingRow::new(format!("Option {n}")), |ui| {
694                            ui.add(Switch::new(self.on.contains(&n)).on_toggle(move |_| n));
695                        });
696                    }
697                })
698                .fill_width();
699            })
700            .fill();
701        }
702    }
703
704    fn row_of(h: &Harness<Long>, n: usize) -> Option<usize> {
705        let label = n.to_string();
706        h.screen().lines().position(|line| {
707            let words: Vec<&str> = line.split_whitespace().collect();
708            words.windows(2).any(|pair| pair[0] == "Option" && pair[1] == label)
709        })
710    }
711
712    #[test]
713    fn a_list_taller_than_its_scroll_view_keeps_the_clicked_row_in_place_and_follows_the_keys() {
714        let mut h = Harness::new(Long::default(), 40, 20);
715        h.set_reduced_motion(true);
716        let third = row_of(&h, 3).unwrap_or_else(|| panic!("{}", h.screen()));
717        let (x, y) = h.find("Option 3").unwrap_or_else(|| panic!("{}", h.screen()));
718        h.click(x, y);
719        assert_eq!(row_of(&h, 3), Some(third), "a click does not scroll: {}", h.screen());
720        assert_eq!(row_of(&h, 1), Some(0), "the list stays at its top: {}", h.screen());
721        for _ in 3..25 {
722            h.press("down");
723        }
724        let screen = h.screen();
725        let last = screen
726            .lines()
727            .collect::<Vec<_>>()
728            .iter()
729            .rposition(|line| line.contains("Option"))
730            .unwrap_or_else(|| panic!("{screen}"));
731        assert_eq!(row_of(&h, 25), Some(last), "the selected row is the last one shown: {screen}");
732        h.press("up");
733        assert_eq!(row_of(&h, 25), Some(last), "going back up inside the view does not scroll: {}", h.screen());
734    }
735
736    /// A warning with a button above thirty rows, a field below them, all in a scroll view shorter
737    /// than the page; the list takes the focus when the screen opens.
738    #[derive(Default)]
739    struct Warned {
740        places: Vec<(usize, usize)>,
741        lines: String,
742    }
743
744    #[derive(Clone)]
745    enum WarnedMsg {
746        Place(usize, usize),
747        Lines(String),
748        Understood,
749    }
750
751    impl App for Warned {
752        type Msg = WarnedMsg;
753        fn init(&mut self) -> Command<WarnedMsg> {
754            Command::focus("list")
755        }
756        fn update(&mut self, message: WarnedMsg) -> Command<WarnedMsg> {
757            match message {
758                WarnedMsg::Place(row, index) => self.places.push((row, index)),
759                WarnedMsg::Lines(text) => self.lines = text,
760                WarnedMsg::Understood => {}
761            }
762            Command::none()
763        }
764        fn view(&self, ui: &mut View<'_, WarnedMsg>) {
765            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
766                ui.add(crate::widgets::Text::new("The settings file could not be read"));
767                ui.add(crate::widgets::Button::new("Understood").on_press(WarnedMsg::Understood));
768                SettingsList::show(ui, |list| {
769                    for n in 1..=30 {
770                        list.row(SettingRow::new(format!("Place {n}")), |ui| {
771                            ui.add(
772                                Segmented::new(["Top", "Bottom"]).on_select(move |index| WarnedMsg::Place(n, index)),
773                            );
774                        });
775                    }
776                })
777                .fill_width()
778                .id("list");
779                ui.add(crate::widgets::TextInput::new(&self.lines).placeholder("Lines").on_change(WarnedMsg::Lines));
780            })
781            .fill();
782        }
783    }
784
785    #[test]
786    fn a_long_list_taking_the_focus_shows_its_keyboard_row_and_keeps_what_is_above_it() {
787        let mut h = Harness::new(Warned::default(), 60, 20);
788        h.set_reduced_motion(true);
789        let screen = h.screen();
790        assert!(screen.contains("The settings file could not be read"), "the warning stays in view: {screen}");
791        assert!(screen.contains("Understood"), "and so does its button: {screen}");
792        h.press("down");
793        assert!(h.screen().contains("The settings file"), "moving inside the view does not scroll: {}", h.screen());
794    }
795
796    #[test]
797    fn clicking_back_into_a_long_list_from_a_field_does_not_scroll_and_chooses() {
798        let mut h = Harness::new(Warned::default(), 60, 20);
799        h.set_reduced_motion(true);
800        for _ in 0..40 {
801            h.mouse(crate::event::MouseKind::ScrollDown, 30, 10);
802        }
803        let (x, y) = h.find("Lines").unwrap_or_else(|| panic!("the field is at the end: {}", h.screen()));
804        h.click(x, y).type_text("12");
805        let before = h.screen();
806        let (x, y) = h.find("Place 25").unwrap_or_else(|| panic!("{before}"));
807        let _ = x;
808        let line = h.screen().lines().nth(usize::try_from(y).unwrap_or(0)).unwrap_or_default().to_owned();
809        let byte = line.rfind("Bottom").unwrap_or_else(|| panic!("{before}"));
810        let column = i32::try_from(line[..byte].chars().count()).unwrap_or(0);
811        h.click(column + 1, y);
812        assert_eq!(h.find("Place 25").map(|(_, row)| row), Some(y), "the page did not move: {}", h.screen());
813        assert_eq!(h.app().places.last(), Some(&(25, 1)), "and the click chose Bottom on that row");
814    }
815
816    #[test]
817    fn a_heading_text_outside_a_list_looks_like_the_lists_own_headings() {
818        for theme in ["monochrome", "nordic", "amber", "iris"] {
819            let mut h = Harness::new(Prefs::default(), 40, 10);
820            h.set_theme(theme);
821            let list = h.env().theme().style("settings-heading", None, &[]);
822            let text = h.env().theme().typography("heading").expect("the heading role");
823            let list = crate::style::WidgetStyle::new(list, 0.0).text();
824            let text = crate::style::WidgetStyle::new(text.clone(), 0.0).text();
825            assert_eq!((text.fg, text.bold), (list.fg, list.bold), "{theme}");
826        }
827    }
828
829    #[test]
830    fn labels_left_controls_anchored_right_with_headings() {
831        let h = Harness::new(Prefs::default(), 40, 10);
832        assert_eq!(
833            h.screen(),
834            "  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"
835                .lines()
836                .map(str::trim_end)
837                .collect::<Vec<_>>()
838                .join("\n")
839                + "\n"
840        );
841    }
842
843    /// Rows with long labels and descriptions, in English or German.
844    struct Wordy {
845        german: bool,
846    }
847
848    impl App for Wordy {
849        type Msg = bool;
850        fn update(&mut self, _: bool) -> Command<bool> {
851            Command::none()
852        }
853        fn view(&self, ui: &mut View<'_, bool>) {
854            let (motion, calm, hour, hour_note) = if self.german {
855                (
856                    "Bewegung",
857                    "Ebenen erscheinen sofort; nichts gleitet oder blendet über.",
858                    "Sitzungen vor dieser Stunde zählen zum Vortag",
859                    "Für Nachteulen, die nach Mitternacht arbeiten.",
860                )
861            } else {
862                (
863                    "Motion",
864                    "Layers appear at once; nothing slides or fades.",
865                    "Sessions before this hour count for the day before",
866                    "For night owls who work past midnight.",
867                )
868            };
869            SettingsList::show(ui, |list| {
870                list.row(SettingRow::new(motion).description(calm), |ui| {
871                    ui.add(Switch::new(true).on_toggle(|on| on));
872                });
873                list.row(SettingRow::new(hour).description(hour_note), |ui| {
874                    ui.add(Segmented::new(["0", "3", "5"]).selected(1).on_select(|_| true));
875                });
876            });
877        }
878    }
879
880    #[test]
881    fn at_forty_columns_descriptions_wrap_and_a_long_label_puts_its_control_below() {
882        for (german, code) in [(false, "en"), (true, "de")] {
883            let mut h = Harness::new(Wordy { german }, 40, 14);
884            h.set_locale(code);
885            let screen = h.screen();
886            assert!(!screen.contains('…'), "{code}: {screen}");
887            let lines: Vec<&str> = screen.lines().collect();
888            // The first label fits beside its switch; the description wraps under it.
889            assert!(lines[1].starts_with("  ") && lines[2].starts_with("  "), "{code}: {screen}");
890            let words: Vec<&str> = lines[1..3].iter().flat_map(|line| line.split_whitespace()).collect();
891            assert!(words.contains(&"nothing") || words.contains(&"nichts"), "{code}: {screen}");
892            // The long label takes its own lines and the control sits under it, at the right.
893            let hour = lines.iter().position(|line| line.contains("Sessions") || line.contains("Sitzungen"));
894            let hour = hour.unwrap_or_else(|| panic!("{code}: {screen}"));
895            let control =
896                lines.iter().position(|line| line.contains(" 0 ")).unwrap_or_else(|| panic!("{code}: {screen}"));
897            assert!(control > hour, "{code}: {screen}");
898            assert!(
899                lines[control].trim_start().starts_with('0'),
900                "the control has the line to itself: {code}: {screen}"
901            );
902            assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{code}: {screen}");
903        }
904    }
905
906    #[test]
907    fn below_forty_columns_every_row_degrades_to_label_then_control_then_description() {
908        for width in [36, 30, 24, 20] {
909            let h = Harness::new(Prefs::default(), width, 16);
910            let screen = h.screen();
911            let lines: Vec<&str> = screen.lines().collect();
912            // At 20 columns the two options of the segmented control cannot fit even on a line
913            // of their own; that cut is the control's, and every text of the list stays whole.
914            let cut: Vec<&&str> = lines.iter().filter(|line| line.contains('…')).collect();
915            assert!(cut.iter().all(|line| width == 20 && line.contains("Cozy")), "{width}: {screen}");
916            let storage = lines.iter().position(|line| line.contains("Storage")).unwrap_or_else(|| panic!("{screen}"));
917            let size = lines.iter().position(|line| line.contains("2.4 GB")).unwrap_or_else(|| panic!("{screen}"));
918            assert!(size > storage, "the value sits under its label: {width}: {screen}");
919            assert!(!lines[size].contains("Storage") && !lines[size].contains("volumes"), "{width}: {screen}");
920            assert!(screen.contains("Motion in lists") || screen.contains("Motion in"), "{width}: {screen}");
921            let density = lines.iter().position(|line| line.contains("Density")).unwrap_or_else(|| panic!("{screen}"));
922            let cozy = lines.iter().position(|line| line.contains("Cozy")).unwrap_or_else(|| panic!("{screen}"));
923            if cozy != density {
924                assert_eq!(cozy, density + 1, "the control right under its label: {width}: {screen}");
925                assert!(
926                    width == 20 || lines[cozy].contains("Compact"),
927                    "a control on its own line has the whole row: {width}: {screen}"
928                );
929            }
930        }
931        for width in [30, 24, 20] {
932            for (german, code) in [(false, "en"), (true, "de")] {
933                let mut h = Harness::new(Wordy { german }, width, 24);
934                h.set_locale(code);
935                let screen = h.screen();
936                assert!(!screen.contains('…'), "{width} {code}: {screen}");
937                assert!(screen.contains("midnight") || screen.contains("Mitternacht"), "{width} {code}: {screen}");
938                assert!(screen.contains(" 0    3    5") || screen.contains("0    3    5"), "{width} {code}: {screen}");
939            }
940        }
941    }
942
943    #[test]
944    fn a_row_reports_the_height_it_wraps_to() {
945        let row = SettingRow::<()>::new("Sessions before this hour count for the day before")
946            .description("For night owls who work past midnight.");
947        let wide = row.lines(9, false, 100);
948        assert_eq!((wide.height(), wide.control_row(), wide.description_row()), (2, 0, 1));
949        let narrow = row.lines(9, false, 40);
950        assert_eq!((narrow.label.len(), narrow.control_row(), narrow.description_row()), (2, 2, 3));
951        assert_eq!(narrow.height(), 5, "two label lines, the control, two description lines");
952        let squeezed = SettingRow::<()>::new("Density").lines(9, true, 40);
953        assert_eq!((squeezed.control_row(), squeezed.height()), (1, 2), "a squeezed control goes under its label");
954    }
955
956    #[derive(Default)]
957    struct Hinted {
958        index: bool,
959        chromium: bool,
960        theme: bool,
961        wrap: bool,
962    }
963
964    #[derive(Clone)]
965    enum HintedMsg {
966        Index(bool),
967        Chromium(bool),
968        Theme(bool),
969    }
970
971    impl App for Hinted {
972        type Msg = HintedMsg;
973        fn update(&mut self, msg: HintedMsg) -> Command<HintedMsg> {
974            match msg {
975                HintedMsg::Index(on) => self.index = on,
976                HintedMsg::Chromium(on) => self.chromium = on,
977                HintedMsg::Theme(on) => self.theme = on,
978            }
979            Command::none()
980        }
981        fn view(&self, ui: &mut View<'_, HintedMsg>) {
982            SettingsList::show(ui, |list| {
983                list.wrap(self.wrap);
984                list.row(
985                    SettingRow::new("Index")
986                        .hint("Maps the code so questions find it fast, without reading every file again"),
987                    |ui| {
988                        ui.add(Switch::new(self.index).on_toggle(HintedMsg::Index));
989                    },
990                );
991                list.row(SettingRow::new("Chromium"), |ui| {
992                    ui.add_with(crate::widgets::Tooltip::new("Not installed here").on_focus(true), |ui| {
993                        ui.add(Switch::new(self.chromium).on_toggle(HintedMsg::Chromium));
994                    });
995                });
996                list.row(SettingRow::new("Theme"), |ui| {
997                    ui.add(Switch::new(self.theme).on_toggle(HintedMsg::Theme));
998                });
999            });
1000        }
1001    }
1002
1003    fn hinted(wrap: bool) -> Harness<Hinted> {
1004        let mut h = Harness::new(Hinted { wrap, ..Hinted::default() }, 50, 8);
1005        h.set_reduced_motion(true);
1006        h
1007    }
1008
1009    #[test]
1010    fn the_keyboards_row_shows_its_hint_and_its_control_keeps_the_keys() {
1011        let mut h = hinted(false);
1012        assert!(!h.screen().contains("Maps the code"), "no hint before anything reaches the row");
1013        h.press("tab");
1014        assert!(
1015            h.screen().lines().nth(1).is_some_and(|line| line.contains("Maps the code")),
1016            "the hint hangs under the keyboard's row"
1017        );
1018        h.press("space");
1019        assert!(h.app().index, "space still reaches the switch of a row with a hint");
1020        h.press("down");
1021        assert!(!h.screen().contains("Maps the code"), "the hint leaves with the keyboard's row");
1022        assert!(h.screen().contains("Not installed here"), "a tooltip on focus shows on the keyboard's row");
1023        h.press("space");
1024        assert!(h.app().chromium, "space reaches a switch inside a tooltip");
1025        h.press("down");
1026        assert!(!h.screen().contains("Not installed here"));
1027        h.press("enter");
1028        assert!(h.app().theme);
1029    }
1030
1031    #[test]
1032    fn a_resting_pointer_shows_the_hint_without_covering_a_control() {
1033        let mut h = hinted(false);
1034        let before: Vec<_> = (0..50).map(|x| (h.bg(x, 1), h.fg(x, 1))).collect();
1035        h.hover(4, 0);
1036        assert!(!h.screen().contains("Maps the code"), "the pointer waits for the hover delay");
1037        h.advance(Duration::from_secs(1));
1038        assert!(h.screen().lines().nth(1).is_some_and(|line| line.contains("Maps the")));
1039        let covered: Vec<u16> = (0..50).filter(|x| (h.bg(*x, 1), h.fg(*x, 1)) != before[usize::from(*x)]).collect();
1040        // The switch of the row below is the one part of that line drawn on a tone of its own.
1041        let switch = (0..50u16).find(|x| before[usize::from(*x)].0 != before[0].0).unwrap_or(50);
1042        assert!(switch < 50 && !covered.is_empty());
1043        assert!(
1044            covered.iter().all(|x| *x < switch),
1045            "the hint {covered:?} stays clear of the switch below, which starts at {switch}"
1046        );
1047        h.hover(4, 6).advance(Duration::from_secs(1));
1048        assert!(!h.screen().contains("Maps the code"));
1049    }
1050
1051    #[test]
1052    fn a_wrapping_list_goes_round_its_rows_and_stops_otherwise() {
1053        let mut h = hinted(true);
1054        h.press("tab").press("up").press("space");
1055        assert!(h.app().theme, "up on the first row reaches the last");
1056        h.press("down");
1057        assert!(h.screen().contains("Maps the code"), "down on the last row reaches the first");
1058        let mut h = hinted(false);
1059        h.press("tab").press("up").press("space");
1060        assert!(h.app().index && !h.app().theme);
1061    }
1062
1063    #[test]
1064    fn keyboard_moves_rows_and_drives_the_selected_control() {
1065        let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 10);
1066        h.press("tab");
1067        let theme = h.env().theme();
1068        assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
1069        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
1070        h.press("space");
1071        assert!(h.app().animations);
1072        h.press("down").press("right");
1073        assert_eq!(h.app().density, 1);
1074        h.press("down").press("enter");
1075        assert_eq!(h.app().opened, 1, "the disabled row is skipped");
1076        h.press("up");
1077        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
1078    }
1079
1080    #[test]
1081    fn the_pointer_carries_the_keyboards_row() {
1082        let mut h = Harness::new(Prefs::default(), 40, 10);
1083        h.press("tab");
1084        assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌  Animations")));
1085        h.hover(6, 7);
1086        let screen = h.screen();
1087        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
1088        assert_eq!(
1089            raised,
1090            ["▌  Storage used by images and volumes", "▌                               2.4 GB"],
1091            "one raised row, both its lines:\n{screen}"
1092        );
1093        assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
1094        assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
1095        h.press("up");
1096        let screen = h.screen();
1097        let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
1098        assert_eq!(raised, ["▌  Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
1099    }
1100
1101    #[test]
1102    fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
1103        let mut h = Harness::new(Prefs::default(), 40, 10);
1104        let before = h.find("Cozy");
1105        h.hover(4, 3);
1106        assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌  Density")));
1107        assert_eq!(h.find("Cozy"), before);
1108        h.hover(before.map_or(0, |(x, _)| x), 3);
1109        assert!(
1110            h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
1111            "the row stays lit over its control"
1112        );
1113        h.click_text("Compact");
1114        assert_eq!(h.app().density, 1);
1115        h.click_text("Storage");
1116        assert_eq!(h.app().opened, 1);
1117    }
1118
1119    struct Nested;
1120
1121    impl App for Nested {
1122        type Msg = ();
1123        fn update(&mut self, (): ()) -> Command<()> {
1124            Command::none()
1125        }
1126        fn view(&self, ui: &mut View<'_, ()>) {
1127            SettingsList::show(ui, |list| {
1128                list.row(SettingRow::new("Theme"), |ui| {
1129                    ui.add(Segmented::new(["Dark", "Light"]).selected(0));
1130                });
1131                list.row(SettingRow::new("Everywhere").description("In every application").nested(true), |ui| {
1132                    ui.add(Switch::new(true));
1133                });
1134            });
1135        }
1136    }
1137
1138    #[test]
1139    fn a_nested_row_starts_two_cells_further_in_and_keeps_its_control_in_place() {
1140        let mut h = Harness::new(Nested, 40, 4);
1141        let (theme_x, _) = h.find("Theme").expect("parent");
1142        let (nested_x, _) = h.find("Everywhere").expect("nested");
1143        let (description_x, _) = h.find("In every").expect("description");
1144        assert_eq!((nested_x, description_x), (theme_x + 2, theme_x + 2), "{}", h.screen());
1145        h.resize(20, 6);
1146        let (nested_x, _) = h.find("Everywhere").expect("nested, narrow");
1147        assert_eq!(nested_x, theme_x + 2, "{}", h.screen());
1148    }
1149
1150    /// A settings page with a time and a duration, as qfocus's settings have.
1151    struct Clock {
1152        turn: crate::date::TimeOfDay,
1153        away: std::time::Duration,
1154    }
1155
1156    #[derive(Clone)]
1157    enum ClockMsg {
1158        Turn(crate::date::TimeOfDay),
1159        Away(std::time::Duration),
1160    }
1161
1162    impl App for Clock {
1163        type Msg = ClockMsg;
1164        fn update(&mut self, msg: ClockMsg) -> Command<ClockMsg> {
1165            match msg {
1166                ClockMsg::Turn(time) => self.turn = time,
1167                ClockMsg::Away(duration) => self.away = duration,
1168            }
1169            Command::none()
1170        }
1171        fn view(&self, ui: &mut View<'_, ClockMsg>) {
1172            SettingsList::show(ui, |list| {
1173                list.row(SettingRow::new("Day turns at"), |ui| {
1174                    ui.add(crate::widgets::TimeInput::new(self.turn).on_change(ClockMsg::Turn));
1175                });
1176                list.row(SettingRow::new("Away after"), |ui| {
1177                    ui.add(crate::widgets::DurationInput::new(self.away).on_change(ClockMsg::Away));
1178                });
1179            });
1180        }
1181    }
1182
1183    fn clock() -> Harness<Clock> {
1184        let app = Clock { turn: crate::date::TimeOfDay::new(4, 0, 0), away: std::time::Duration::from_secs(15 * 60) };
1185        let mut h = Harness::new(app, 60, 6);
1186        h.set_reduced_motion(true).render();
1187        h
1188    }
1189
1190    #[test]
1191    fn two_digits_typed_into_a_time_in_a_settings_row_make_one_value() {
1192        let mut h = clock();
1193        let (x, y) = h.find("04").unwrap_or_else(|| panic!("the hour is on screen:\n{}", h.screen()));
1194        h.click(x, y);
1195        h.type_text("12");
1196        assert_eq!(
1197            h.app().turn,
1198            crate::date::TimeOfDay::new(12, 0, 0),
1199            "two digits make twelve, not two:\n{}",
1200            h.screen()
1201        );
1202        let (x, y) = h.find("00").unwrap_or_else(|| panic!("the minute is on screen:\n{}", h.screen()));
1203        h.click(x, y);
1204        h.type_text("05");
1205        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(12, 5, 0), "the minute took the digits:\n{}", h.screen());
1206    }
1207
1208    #[test]
1209    fn the_keys_reach_the_minute_of_a_time_in_a_settings_row() {
1210        let mut h = clock();
1211        h.press("tab");
1212        h.type_text("07");
1213        h.press("right");
1214        h.type_text("45");
1215        assert_eq!(h.app().turn, crate::date::TimeOfDay::new(7, 45, 0), "{}", h.screen());
1216    }
1217
1218    #[test]
1219    fn two_digits_typed_into_a_duration_in_a_settings_row_go_to_the_part_clicked() {
1220        let mut h = clock();
1221        let (x, y) = h.find("15").unwrap_or_else(|| panic!("the minutes are on screen:\n{}", h.screen()));
1222        h.click(x, y);
1223        h.type_text("05");
1224        assert_eq!(h.app().away, std::time::Duration::from_secs(5 * 60), "the minutes, not the hours:\n{}", h.screen());
1225    }
1226}