1use 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
18const CONTROL_GAP: u16 = 2;
20
21const NEST: u16 = 2;
23
24pub 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 #[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 #[must_use]
44 pub fn description(mut self, description: impl Into<String>) -> Self {
45 self.description = Some(description.into());
46 self
47 }
48
49 #[must_use]
55 pub fn hint(mut self, hint: impl Into<String>) -> Self {
56 self.hint = Some(hint.into());
57 self
58 }
59
60 #[must_use]
62 pub fn disabled(mut self, disabled: bool) -> Self {
63 self.disabled = disabled;
64 self
65 }
66
67 #[must_use]
71 pub fn nested(mut self, nested: bool) -> Self {
72 self.nested = nested;
73 self
74 }
75
76 fn indent(&self) -> u16 {
78 if self.nested { NEST } else { 0 }
79 }
80
81 #[must_use]
84 pub fn on_activate(mut self, message: Msg) -> Self {
85 self.on_activate = Some(message);
86 self
87 }
88
89 fn lines(&self, control: u16, squeezed: bool, width: u16) -> RowLines {
100 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
114fn control_room(width: u16) -> u16 {
117 width.saturating_sub(LEAD + CONTROL_GAP)
118}
119
120struct RowLines {
122 label: Vec<String>,
123 description: Vec<String>,
124 control_below: bool,
126 full: u16,
128 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 fn control_row(&self) -> u16 {
139 if self.control_below { self.label_rows() } else { 0 }
140 }
141
142 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 Row(SettingRow<Msg>, usize),
157}
158
159pub 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 #[must_use]
173 pub fn env(&self) -> &crate::env::Env {
174 self.env
175 }
176
177 pub fn wrap(&mut self, wrap: bool) {
180 self.wrap = wrap;
181 }
182
183 pub fn heading(&mut self, title: impl Into<String>) {
185 self.entries.push(Entry::Heading(title.into()));
186 }
187
188 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
200pub struct SettingsList<Msg> {
233 entries: Vec<Entry<Msg>>,
234 controls: Vec<Node<Msg>>,
235 wrap: bool,
236}
237
238#[derive(Debug, Clone, Copy)]
240struct ShownHint {
241 row: usize,
242 anchor: Rect,
244 width: u16,
246 since: Duration,
247}
248
249#[derive(Debug, Default)]
250struct SettingsMemory {
251 selected: Option<usize>,
252 controls: Vec<Rect>,
254 rows: Vec<Rect>,
256 pointer: Option<(i32, i32)>,
258 reveal: bool,
262 keyed: bool,
265 rested: Option<(usize, Duration)>,
267 hint: Option<ShownHint>,
268}
269
270impl<Msg: Clone + 'static> SettingsList<Msg> {
271 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 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 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 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
361fn 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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}