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