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
17pub struct SettingRow<Msg> {
20 label: String,
21 description: Option<String>,
22 disabled: bool,
23 on_activate: Option<Msg>,
24}
25
26impl<Msg> SettingRow<Msg> {
27 #[must_use]
29 pub fn new(label: impl Into<String>) -> Self {
30 Self { label: label.into(), description: None, disabled: false, on_activate: None }
31 }
32
33 #[must_use]
35 pub fn description(mut self, description: impl Into<String>) -> Self {
36 self.description = Some(description.into());
37 self
38 }
39
40 #[must_use]
42 pub fn disabled(mut self, disabled: bool) -> Self {
43 self.disabled = disabled;
44 self
45 }
46
47 #[must_use]
50 pub fn on_activate(mut self, message: Msg) -> Self {
51 self.on_activate = Some(message);
52 self
53 }
54
55 fn height(&self) -> u16 {
56 1 + u16::from(self.description.is_some())
57 }
58}
59
60enum Entry<Msg> {
61 Heading(String),
62 Row(SettingRow<Msg>, usize),
64}
65
66pub struct SettingsRows<'a, Msg> {
68 entries: Vec<Entry<Msg>>,
69 controls: Vec<Node<Msg>>,
70 env: &'a crate::env::Env,
71 size: crate::geometry::Size,
72 idle: &'a crate::widget::IdleScope<Msg>,
73}
74
75impl<Msg: 'static> SettingsRows<'_, Msg> {
76 pub fn heading(&mut self, title: impl Into<String>) {
78 self.entries.push(Entry::Heading(title.into()));
79 }
80
81 pub fn row(&mut self, row: SettingRow<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) {
85 let mut children = Vec::new();
86 control(&mut View::new(&mut children, self.env, self.size, self.idle));
87 let index = self.controls.len();
88 self.controls.push(Node::new(Flex::new(Axis::Row, children), index));
89 self.entries.push(Entry::Row(row, index));
90 }
91}
92
93pub struct SettingsList<Msg> {
113 entries: Vec<Entry<Msg>>,
114 controls: Vec<Node<Msg>>,
115}
116
117#[derive(Debug, Default)]
118struct SettingsMemory {
119 selected: Option<usize>,
120 controls: Vec<Rect>,
122 rows: Vec<Rect>,
124 pointer: Option<(i32, i32)>,
126}
127
128impl<Msg: Clone + 'static> SettingsList<Msg> {
129 pub fn show<'v>(ui: &'v mut View<'_, Msg>, build: impl FnOnce(&mut SettingsRows<'_, Msg>)) -> NodeMut<'v, Msg> {
131 let (entries, controls) = {
132 let mut rows = SettingsRows {
133 entries: Vec::new(),
134 controls: Vec::new(),
135 env: ui.env(),
136 size: ui.size(),
137 idle: ui.idle_scope(),
138 };
139 build(&mut rows);
140 (rows.entries, rows.controls)
141 };
142 ui.add(Self { entries, controls }).fill_width()
143 }
144
145 fn row(&self, index: usize) -> Option<&SettingRow<Msg>> {
146 self.entries.iter().find_map(|entry| match entry {
147 Entry::Row(row, i) if *i == index => Some(row),
148 _ => None,
149 })
150 }
151
152 fn enabled(&self) -> Vec<usize> {
153 self.entries
154 .iter()
155 .filter_map(|entry| match entry {
156 Entry::Row(row, index) if !row.disabled => Some(*index),
157 _ => None,
158 })
159 .collect()
160 }
161
162 fn current(&self, remembered: Option<usize>) -> Option<usize> {
164 let enabled = self.enabled();
165 remembered.filter(|index| enabled.contains(index)).or_else(|| enabled.first().copied())
166 }
167
168 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
169 match self.row(index).and_then(|row| row.on_activate.clone()) {
170 Some(message) => {
171 cx.flash();
172 cx.emit(message);
173 true
174 }
175 None => false,
176 }
177 }
178}
179
180impl<Msg: Clone + 'static> Widget<Msg> for SettingsList<Msg> {
181 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
182 let mut width = 0u16;
183 let mut height = 0u16;
184 for (position, entry) in self.entries.iter().enumerate() {
185 match entry {
186 Entry::Heading(title) => {
187 height = height.saturating_add(1 + u16::from(position > 0));
188 width = width.max(text::width(title).saturating_add(LEAD));
189 }
190 Entry::Row(row, index) => {
191 let control = cx.measure_child(&self.controls[*index], Size::new(available.width, 1)).width;
192 let label = text::width(&row.label).max(row.description.as_deref().map_or(0, text::width));
193 width = width.max(cells::sum([LEAD, label, 1, CONTROL_GAP * 2, control]));
194 height = height.saturating_add(row.height());
195 }
196 }
197 }
198 Size::new(width, height).min(available)
199 }
200
201 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
202 cx.register_hit(area);
203 let focused = cx.is_focused();
204 let pointer = cx.pointer_within();
205 let slide = cx.env().slide();
206 let enabled = self.enabled();
207 let selected = {
208 let memory = cx.memory::<SettingsMemory>();
209 if pointer != memory.pointer {
212 memory.pointer = pointer;
213 let under = pointer.and_then(|(px, py)| memory.rows.iter().position(|rect| rect.contains(px, py)));
214 if let Some(index) = under.filter(|index| enabled.contains(index)) {
215 memory.selected = Some(index);
216 }
217 }
218 let current = self.current(memory.selected);
219 memory.selected = current;
220 current.filter(|_| focused)
221 };
222 let mut controls = vec![Rect::default(); self.controls.len()];
223 let mut rows = vec![Rect::default(); self.controls.len()];
224 let mut y = area.y;
225 for (position, entry) in self.entries.iter().enumerate() {
226 match entry {
227 Entry::Heading(title) => {
228 if position > 0 {
229 y += 1;
230 }
231 let style = cx.style("settings-heading", None, &[]).text();
232 let budget = area.width.saturating_sub(LEAD + 1);
233 let shown = text::truncate(title, budget).into_owned();
234 cx.text(area.x + i32::from(LEAD), y, &shown, style, budget);
235 y += 1;
236 }
237 Entry::Row(row, index) => {
238 let rect = Rect::new(area.x, y, area.width, row.height());
239 y += i32::from(row.height());
240 rows[*index] = rect;
241 let mut states = Vec::new();
242 if row.disabled {
243 states.push(State::Disabled);
244 } else {
245 let pointed = pointer.is_some_and(|(px, py)| rect.contains(px, py));
246 if pointed && (!focused || selected == Some(*index)) {
247 states.push(State::Hover);
248 }
249 if selected == Some(*index) {
250 states.extend([State::Selected, State::Focus]);
251 }
252 }
253 let style = cx.style("setting-row", None, &states);
254 if let Some(bg) = style.text().bg {
255 cx.clear(rect, bg);
256 }
257 if let Some(color) = style.color("pillar") {
258 for row_y in rect.y..rect.bottom() {
259 cx.pillar(rect.x, row_y, color);
260 }
261 }
262
263 let node = &self.controls[*index];
264 let control_width = cx.measure_child(node, Size::new(area.width / 2, 1)).width;
265 let control_x = rect.right() - i32::from(CONTROL_GAP + control_width);
266 let control = Rect::new(control_x, rect.y, control_width, 1);
267 controls[*index] = control;
268 cx.paint_child_unfocusable(node, control);
269
270 let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
271 let shift = u16::from(slide && raised);
272 let text_x = rect.x + i32::from(LEAD);
273 let budget = clamp_u16(control_x - i32::from(CONTROL_GAP) - text_x).saturating_sub(1);
275 let x = text_x + i32::from(shift);
276 let label_style = cx.style("setting-label", None, &states).text();
277 let label = text::truncate(&row.label, budget).into_owned();
278 cx.text(x, rect.y, &label, CellStyle { bg: None, ..label_style }, budget);
279 if let Some(description) = &row.description {
280 let style = cx.style("setting-description", None, &states).text();
281 let shown = text::truncate(description, budget).into_owned();
282 cx.text(x, rect.y + 1, &shown, CellStyle { bg: None, ..style }, budget);
283 }
284 }
285 }
286 }
287 let memory = cx.memory::<SettingsMemory>();
288 memory.controls = controls;
289 memory.rows = rows;
290 }
291
292 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
293 let enabled = self.enabled();
294 if enabled.is_empty() {
295 return false;
296 }
297 let current = self.current(cx.memory::<SettingsMemory>().selected);
298 match event {
299 Event::Key(key) => {
300 let position = current.and_then(|index| enabled.iter().position(|i| *i == index)).unwrap_or(0);
301 let target = if key.is_plain(Key::Up) {
302 Some(position.saturating_sub(1))
303 } else if key.is_plain(Key::Down) {
304 Some((position + 1).min(enabled.len() - 1))
305 } else {
306 None
307 };
308 if let Some(target) = target {
309 cx.memory::<SettingsMemory>().selected = Some(enabled[target]);
310 return true;
311 }
312 let Some(index) = current else {
313 return false;
314 };
315 let rect = cx.memory::<SettingsMemory>().controls.get(index).copied().unwrap_or_default();
316 let used =
318 self.controls[index].widget.children().iter().any(|control| cx.forward(control, rect, event));
319 if used {
320 return true;
321 }
322 if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
323 return self.activate(cx, index);
324 }
325 if key.is_plain(Key::Home) || key.is_plain(Key::End) {
326 let target = if key.is_plain(Key::Home) { enabled[0] } else { enabled[enabled.len() - 1] };
327 cx.memory::<SettingsMemory>().selected = Some(target);
328 return true;
329 }
330 false
331 }
332 Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
333 let rows = cx.memory::<SettingsMemory>().rows.clone();
334 let Some(index) = rows.iter().position(|rect| rect.contains(mouse.x, mouse.y)) else {
335 return false;
336 };
337 if !enabled.contains(&index) {
338 return true;
339 }
340 cx.memory::<SettingsMemory>().selected = Some(index);
341 cx.request_focus();
342 self.activate(cx, index);
343 true
344 }
345 _ => false,
346 }
347 }
348
349 fn focusable(&self) -> bool {
350 !self.enabled().is_empty()
351 }
352
353 fn children(&self) -> &[Node<Msg>] {
354 &self.controls
355 }
356
357 fn children_mut(&mut self) -> &mut [Node<Msg>] {
358 &mut self.controls
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::runtime::{App, Command, Harness};
366 use crate::widgets::{Segmented, Switch};
367
368 #[derive(Default)]
369 struct Prefs {
370 animations: bool,
371 density: usize,
372 opened: usize,
373 telemetry_locked: bool,
374 }
375
376 #[derive(Clone)]
377 enum Msg {
378 Animations(bool),
379 Density(usize),
380 Open,
381 }
382
383 impl App for Prefs {
384 type Msg = Msg;
385 fn update(&mut self, msg: Msg) -> Command<Msg> {
386 match msg {
387 Msg::Animations(on) => self.animations = on,
388 Msg::Density(index) => self.density = index,
389 Msg::Open => self.opened += 1,
390 }
391 Command::none()
392 }
393 fn view(&self, ui: &mut View<'_, Msg>) {
394 SettingsList::show(ui, |list| {
395 list.heading("APPEARANCE");
396 list.row(SettingRow::new("Animations").description("Motion in lists"), |ui| {
397 ui.add(Switch::new(self.animations).on_toggle(Msg::Animations));
398 });
399 list.row(SettingRow::new("Density"), |ui| {
400 ui.add(Segmented::new(["Cozy", "Compact"]).selected(self.density).on_select(Msg::Density));
401 });
402 list.heading("PRIVACY");
403 list.row(SettingRow::new("Telemetry").disabled(self.telemetry_locked), |ui| {
404 ui.add(Switch::new(false).disabled(self.telemetry_locked));
405 });
406 list.row(SettingRow::new("Storage used by images and volumes").on_activate(Msg::Open), |ui| {
407 ui.add(Text::new("2.4 GB"));
408 });
409 })
410 .id("settings");
411 }
412 }
413
414 use crate::widgets::Text;
415
416 #[test]
417 fn labels_left_controls_anchored_right_with_headings() {
418 let h = Harness::new(Prefs::default(), 40, 8);
419 assert_eq!(
420 h.screen(),
421 " APPEARANCE\n Animations \n Motion in lists\n Density Cozy Compact\n\n PRIVACY\n Telemetry\n Storage used by images and… 2.4 GB\n"
422 .lines()
423 .map(str::trim_end)
424 .collect::<Vec<_>>()
425 .join("\n")
426 + "\n"
427 );
428 }
429
430 #[test]
431 fn keyboard_moves_rows_and_drives_the_selected_control() {
432 let mut h = Harness::new(Prefs { telemetry_locked: true, ..Prefs::default() }, 40, 8);
433 h.press("tab");
434 let theme = h.env().theme();
435 assert_eq!(h.bg(20, 1), theme.color("active"), "the first row is selected on focus");
436 assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌ Animations")));
437 h.press("space");
438 assert!(h.app().animations);
439 h.press("down").press("right");
440 assert_eq!(h.app().density, 1);
441 h.press("down").press("enter");
442 assert_eq!(h.app().opened, 1, "the disabled row is skipped");
443 h.press("up");
444 assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌ Density")));
445 }
446
447 #[test]
448 fn the_pointer_carries_the_keyboards_row() {
449 let mut h = Harness::new(Prefs::default(), 40, 8);
450 h.press("tab");
451 assert!(h.screen().lines().nth(1).is_some_and(|line| line.starts_with("▌ Animations")));
452 h.hover(6, 7);
453 let screen = h.screen();
454 let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
455 assert_eq!(raised, ["▌ Storage used by images and… 2.4 GB"], "one raised row:\n{screen}");
456 assert_eq!(h.bg(20, 7), h.env().theme().color("active"), "the pointer's row is the keyboard's row");
457 assert_ne!(h.bg(20, 1), h.env().theme().color("active"));
458 h.press("up");
459 let screen = h.screen();
460 let raised: Vec<&str> = screen.lines().filter(|line| line.starts_with('▌')).collect();
461 assert_eq!(raised, ["▌ Telemetry"], "the keyboard continues from the pointer's row:\n{screen}");
462 }
463
464 #[test]
465 fn hover_slides_the_label_but_not_the_control_and_clicks_reach_controls() {
466 let mut h = Harness::new(Prefs::default(), 40, 8);
467 let before = h.find("Cozy");
468 h.hover(4, 3);
469 assert!(h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌ Density")));
470 assert_eq!(h.find("Cozy"), before);
471 h.hover(before.map_or(0, |(x, _)| x), 3);
472 assert!(
473 h.screen().lines().nth(3).is_some_and(|line| line.starts_with("▌")),
474 "the row stays lit over its control"
475 );
476 h.click_text("Compact");
477 assert_eq!(h.app().density, 1);
478 h.click_text("Storage");
479 assert_eq!(h.app().opened, 1);
480 }
481}