Another project
1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::a11y::{AccessNode, Role};
4use crate::frame::{FrameCtx, InteractDeclaration};
5use crate::hit_test::Sense;
6use crate::input::{KeyCode, ModifierMask, NamedKey};
7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
8use crate::strings::StringKey;
9use crate::theme::{Border, Color, Step12, StrokeWidth};
10use crate::widget_id::{WidgetId, WidgetKey};
11
12use super::keys::{TakeKey, take_key};
13use super::paint::{GlyphMark, LabelText, WidgetPaint};
14use super::scrollbar::{row_window, wheel_scroll, window_scrollbar};
15use super::visuals::push_focus_ring;
16
17#[derive(Copy, Clone, Debug, PartialEq, Eq)]
18pub enum SortDirection {
19 Ascending,
20 Descending,
21}
22
23impl SortDirection {
24 #[must_use]
25 pub const fn flip(self) -> Self {
26 match self {
27 Self::Ascending => Self::Descending,
28 Self::Descending => Self::Ascending,
29 }
30 }
31}
32
33#[derive(Copy, Clone, Debug, PartialEq, Eq)]
34pub struct TableSort {
35 pub column: WidgetId,
36 pub direction: SortDirection,
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct ListItem {
41 pub id: WidgetId,
42 pub label: LabelText,
43}
44
45#[derive(Copy, Clone, Debug, PartialEq, Eq)]
46pub enum ListSelectionMode {
47 Single,
48 Multi,
49}
50
51#[derive(Clone, Debug, Default, PartialEq)]
52pub struct ListViewState {
53 pub selection: BTreeSet<WidgetId>,
54 pub focused: Option<WidgetId>,
55 pub scroll_offset: LayoutPx,
56}
57
58#[derive(Debug, PartialEq)]
59pub struct ListView<'a, 'state> {
60 pub id: WidgetId,
61 pub rect: LayoutRect,
62 pub label: StringKey,
63 pub items: &'a [ListItem],
64 pub state: &'state mut ListViewState,
65 pub mode: ListSelectionMode,
66 pub row_height: LayoutPx,
67}
68
69impl<'a, 'state> ListView<'a, 'state> {
70 #[must_use]
71 pub const fn new(
72 id: WidgetId,
73 rect: LayoutRect,
74 label: StringKey,
75 items: &'a [ListItem],
76 state: &'state mut ListViewState,
77 ) -> Self {
78 Self {
79 id,
80 rect,
81 label,
82 items,
83 state,
84 mode: ListSelectionMode::Single,
85 row_height: LayoutPx::new(22.0),
86 }
87 }
88
89 #[must_use]
90 pub const fn mode(self, mode: ListSelectionMode) -> Self {
91 Self { mode, ..self }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq)]
96pub struct ListViewResponse {
97 pub activated: Option<WidgetId>,
98 pub opened: Option<WidgetId>,
99 pub paint: Vec<WidgetPaint>,
100}
101
102#[must_use]
103pub fn show_list_view(ctx: &mut FrameCtx<'_>, view: ListView<'_, '_>) -> ListViewResponse {
104 let ListView {
105 id,
106 rect,
107 label,
108 items,
109 state,
110 mode,
111 row_height,
112 } = view;
113 ctx.a11y
114 .push(id, rect, AccessNode::new(Role::ListBox).with_label(label));
115 let mut paint = vec![WidgetPaint::Surface {
116 rect,
117 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
118 border: None,
119 radius: ctx.theme().radius.none,
120 elevation: None,
121 }];
122 let requested = wheel_scroll(ctx, rect, state.scroll_offset);
123 let window = row_window(rect, items.len(), row_height, requested);
124 state.scroll_offset = window.offset;
125 let content_rect = window.content_rect;
126 let entry_id = state
127 .focused
128 .filter(|f| items.iter().any(|i| i.id == *f))
129 .or_else(|| items.first().map(|i| i.id));
130 if let Some(id) = entry_id {
131 ctx.focus.register_tab_stop(id);
132 }
133 let mut activated: Option<WidgetId> = None;
134 let mut opened: Option<WidgetId> = None;
135 items
136 .iter()
137 .enumerate()
138 .skip(window.first_row)
139 .take(window.last_row - window.first_row)
140 .for_each(|(idx, item)| {
141 let row_rect = list_row_rect(content_rect, idx - window.first_row, row_height);
142 let selected = state.selection.contains(&item.id);
143 let interaction = ctx.interact(
144 InteractDeclaration::new(item.id, row_rect, Sense::INTERACTIVE)
145 .focusable(false)
146 .active(selected)
147 .a11y(
148 AccessNode::new(Role::ListBoxOption)
149 .with_label_text(item.label.clone())
150 .with_selected(selected),
151 ),
152 );
153 let live_focused = ctx.is_focused(item.id);
154 if interaction.click() {
155 apply_list_selection(state, item.id, ctx.input.modifiers, mode);
156 state.focused = Some(item.id);
157 ctx.focus.request_focus(item.id);
158 if activated.is_none() {
159 activated = Some(item.id);
160 }
161 }
162 if interaction.double_click() && opened.is_none() {
163 opened = Some(item.id);
164 }
165 let fill = list_row_fill(ctx, &interaction, state.selection.contains(&item.id));
166 paint.push(WidgetPaint::Surface {
167 rect: row_rect,
168 fill,
169 border: None,
170 radius: ctx.theme().radius.none,
171 elevation: None,
172 });
173 paint.push(WidgetPaint::Label {
174 rect: row_rect,
175 text: item.label.clone(),
176 color: ctx.theme().colors.text_primary(),
177 role: ctx.theme().typography.body,
178 });
179 push_focus_ring(
180 ctx,
181 &mut paint,
182 row_rect,
183 ctx.theme().radius.none,
184 live_focused,
185 );
186 });
187 let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset);
188 state.scroll_offset = next_offset;
189 paint.extend(bar_paint);
190 handle_list_keyboard(ctx, items, state);
191 ListViewResponse {
192 activated,
193 opened,
194 paint,
195 }
196}
197
198fn list_row_rect(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect {
199 #[allow(
200 clippy::cast_precision_loss,
201 reason = "list row index fits f32 mantissa"
202 )]
203 let i = idx as f32;
204 LayoutRect::new(
205 LayoutPos::new(
206 view.origin.x,
207 LayoutPx::new(view.origin.y.value() + i * row_height.value()),
208 ),
209 LayoutSize::new(view.size.width, row_height),
210 )
211}
212
213fn list_row_fill(
214 ctx: &FrameCtx<'_>,
215 interaction: &crate::hit_test::Interaction,
216 selected: bool,
217) -> Color {
218 let neutral = ctx.theme().colors.neutral;
219 if selected {
220 ctx.theme().colors.accent.step(Step12::HOVER_BG)
221 } else if interaction.hover() {
222 neutral.step(Step12::HOVER_BG)
223 } else {
224 Color::TRANSPARENT
225 }
226}
227
228fn apply_list_selection(
229 state: &mut ListViewState,
230 id: WidgetId,
231 modifiers: ModifierMask,
232 mode: ListSelectionMode,
233) {
234 match mode {
235 ListSelectionMode::Single => {
236 state.selection = BTreeSet::from([id]);
237 }
238 ListSelectionMode::Multi => {
239 if modifiers.contains(ModifierMask::CTRL) {
240 if !state.selection.insert(id) {
241 state.selection.remove(&id);
242 }
243 } else {
244 state.selection = BTreeSet::from([id]);
245 }
246 }
247 }
248}
249
250fn handle_list_keyboard(ctx: &mut FrameCtx<'_>, items: &[ListItem], state: &mut ListViewState) {
251 let in_focus = ctx
252 .focus
253 .focused()
254 .is_some_and(|f| items.iter().any(|i| i.id == f));
255 if !in_focus {
256 return;
257 }
258 let event = take_key(
259 ctx.input,
260 &[
261 TakeKey::named(NamedKey::ArrowUp),
262 TakeKey::named(NamedKey::ArrowDown),
263 TakeKey::named(NamedKey::Home),
264 TakeKey::named(NamedKey::End),
265 ],
266 );
267 let Some(event) = event else { return };
268 let focused = ctx.focus.focused();
269 let current = focused.and_then(|f| items.iter().position(|i| i.id == f));
270 let target = match event.code {
271 KeyCode::Named(NamedKey::ArrowDown) => current.and_then(|i| items.get(i + 1)),
272 KeyCode::Named(NamedKey::ArrowUp) => {
273 current.and_then(|i| if i == 0 { None } else { items.get(i - 1) })
274 }
275 KeyCode::Named(NamedKey::Home) => items.first(),
276 KeyCode::Named(NamedKey::End) => items.last(),
277 _ => None,
278 };
279 if let Some(target) = target {
280 ctx.focus.request_focus(target.id);
281 state.focused = Some(target.id);
282 }
283}
284
285#[derive(Clone, Debug, PartialEq)]
286pub struct TableColumn {
287 pub id: WidgetId,
288 pub label: StringKey,
289 pub width: LayoutPx,
290 pub sortable: bool,
291 pub min_width: LayoutPx,
292}
293
294impl TableColumn {
295 #[must_use]
296 pub const fn new(id: WidgetId, label: StringKey, width: LayoutPx) -> Self {
297 Self {
298 id,
299 label,
300 width,
301 sortable: true,
302 min_width: LayoutPx::new(40.0),
303 }
304 }
305}
306
307#[derive(Clone, Debug, PartialEq, Eq)]
308pub struct TableRow<const N: usize> {
309 pub id: WidgetId,
310 pub cells: [StringKey; N],
311}
312
313#[derive(Copy, Clone, Debug, PartialEq)]
314pub struct ResizeAnchor {
315 pub column: WidgetId,
316 pub start_width: LayoutPx,
317}
318
319#[derive(Clone, Debug, Default, PartialEq)]
320pub struct TableState {
321 pub sort: Option<TableSort>,
322 pub selection: BTreeSet<WidgetId>,
323 pub focused: Option<WidgetId>,
324 pub column_widths: BTreeMap<WidgetId, LayoutPx>,
325 pub resizing: Option<ResizeAnchor>,
326}
327
328#[derive(Debug, PartialEq)]
329pub struct Table<'a, 'state, const N: usize> {
330 pub id: WidgetId,
331 pub rect: LayoutRect,
332 pub label: StringKey,
333 pub columns: &'a [TableColumn; N],
334 pub rows: &'a [TableRow<N>],
335 pub state: &'state mut TableState,
336 pub header_height: LayoutPx,
337 pub row_height: LayoutPx,
338 pub mode: ListSelectionMode,
339}
340
341impl<'a, 'state, const N: usize> Table<'a, 'state, N> {
342 #[must_use]
343 pub const fn new(
344 id: WidgetId,
345 rect: LayoutRect,
346 label: StringKey,
347 columns: &'a [TableColumn; N],
348 rows: &'a [TableRow<N>],
349 state: &'state mut TableState,
350 ) -> Self {
351 Self {
352 id,
353 rect,
354 label,
355 columns,
356 rows,
357 state,
358 header_height: LayoutPx::new(24.0),
359 row_height: LayoutPx::new(22.0),
360 mode: ListSelectionMode::Single,
361 }
362 }
363
364 #[must_use]
365 pub const fn mode(self, mode: ListSelectionMode) -> Self {
366 Self { mode, ..self }
367 }
368}
369
370#[derive(Clone, Debug, PartialEq)]
371pub struct TableResponse {
372 pub activated_row: Option<WidgetId>,
373 pub sort_changed: Option<TableSort>,
374 pub column_resized: Option<(WidgetId, LayoutPx)>,
375 pub paint: Vec<WidgetPaint>,
376}
377
378const RESIZE_HANDLE_PX: f32 = 6.0;
379
380#[must_use]
381pub fn show_table<const N: usize>(
382 ctx: &mut FrameCtx<'_>,
383 table: Table<'_, '_, N>,
384) -> TableResponse {
385 let Table {
386 id,
387 rect,
388 label,
389 columns,
390 rows,
391 state,
392 header_height,
393 row_height,
394 mode,
395 } = table;
396 ctx.a11y
397 .push(id, rect, AccessNode::new(Role::Table).with_label(label));
398 let (column_widths, column_x) = compute_column_layout(columns, state, rect);
399 let mut paint = vec![WidgetPaint::Surface {
400 rect,
401 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
402 border: None,
403 radius: ctx.theme().radius.none,
404 elevation: None,
405 }];
406 let mut sort_changed: Option<TableSort> = None;
407 let mut column_resized: Option<(WidgetId, LayoutPx)> = None;
408 paint.extend(draw_headers(
409 ctx,
410 HeadersArgs {
411 columns,
412 column_widths: &column_widths,
413 column_x: &column_x,
414 row_y: rect.origin.y,
415 header_height,
416 state,
417 },
418 &mut sort_changed,
419 &mut column_resized,
420 ));
421 let entry_id = state
422 .focused
423 .filter(|f| rows.iter().any(|r| r.id == *f))
424 .or_else(|| rows.first().map(|r| r.id));
425 if let Some(id) = entry_id {
426 ctx.focus.register_tab_stop(id);
427 }
428 let mut activated_row: Option<WidgetId> = None;
429 rows.iter().enumerate().for_each(|(row_idx, row)| {
430 paint.extend(draw_row(
431 ctx,
432 RowArgs {
433 row,
434 row_idx,
435 rect,
436 header_height,
437 row_height,
438 column_widths: &column_widths,
439 column_x: &column_x,
440 state,
441 mode,
442 },
443 &mut activated_row,
444 ));
445 });
446 prune_column_widths(state, columns);
447 TableResponse {
448 activated_row,
449 sort_changed,
450 column_resized,
451 paint,
452 }
453}
454
455fn prune_column_widths<const N: usize>(state: &mut TableState, columns: &[TableColumn; N]) {
456 let live: BTreeSet<WidgetId> = columns.iter().map(|c| c.id).collect();
457 state.column_widths.retain(|k, _| live.contains(k));
458}
459
460fn compute_column_layout<const N: usize>(
461 columns: &[TableColumn; N],
462 state: &TableState,
463 rect: LayoutRect,
464) -> (Vec<LayoutPx>, Vec<LayoutPx>) {
465 let widths: Vec<LayoutPx> = columns
466 .iter()
467 .map(|c| state.column_widths.get(&c.id).copied().unwrap_or(c.width))
468 .collect();
469 let xs: Vec<LayoutPx> = widths
470 .iter()
471 .scan(rect.origin.x, |x, w| {
472 let cur = *x;
473 *x = LayoutPx::new(x.value() + w.value());
474 Some(cur)
475 })
476 .collect();
477 (widths, xs)
478}
479
480struct HeadersArgs<'a, const N: usize> {
481 columns: &'a [TableColumn; N],
482 column_widths: &'a [LayoutPx],
483 column_x: &'a [LayoutPx],
484 row_y: LayoutPx,
485 header_height: LayoutPx,
486 state: &'a mut TableState,
487}
488
489fn draw_headers<const N: usize>(
490 ctx: &mut FrameCtx<'_>,
491 args: HeadersArgs<'_, N>,
492 sort_changed: &mut Option<TableSort>,
493 column_resized: &mut Option<(WidgetId, LayoutPx)>,
494) -> Vec<WidgetPaint> {
495 let HeadersArgs {
496 columns,
497 column_widths,
498 column_x,
499 row_y,
500 header_height,
501 state,
502 } = args;
503 let mut paint = Vec::new();
504 columns
505 .iter()
506 .zip(column_widths.iter())
507 .zip(column_x.iter())
508 .for_each(|((column, width), x)| {
509 let header_rect = LayoutRect::new(
510 LayoutPos::new(*x, row_y),
511 LayoutSize::new(*width, header_height),
512 );
513 paint.extend(draw_header_cell(
514 ctx,
515 column,
516 header_rect,
517 state,
518 sort_changed,
519 column_resized,
520 ));
521 });
522 paint
523}
524
525struct RowArgs<'a, const N: usize> {
526 row: &'a TableRow<N>,
527 row_idx: usize,
528 rect: LayoutRect,
529 header_height: LayoutPx,
530 row_height: LayoutPx,
531 column_widths: &'a [LayoutPx],
532 column_x: &'a [LayoutPx],
533 state: &'a mut TableState,
534 mode: ListSelectionMode,
535}
536
537fn draw_row<const N: usize>(
538 ctx: &mut FrameCtx<'_>,
539 args: RowArgs<'_, N>,
540 activated_row: &mut Option<WidgetId>,
541) -> Vec<WidgetPaint> {
542 let RowArgs {
543 row,
544 row_idx,
545 rect,
546 header_height,
547 row_height,
548 column_widths,
549 column_x,
550 state,
551 mode,
552 } = args;
553 let row_y = rect.origin.y.value() + header_height.value() + row_idx_to_y(row_idx, row_height);
554 let row_rect = LayoutRect::new(
555 LayoutPos::new(rect.origin.x, LayoutPx::new(row_y)),
556 LayoutSize::new(rect.size.width, row_height),
557 );
558 let selected = state.selection.contains(&row.id);
559 let label = row
560 .cells
561 .first()
562 .copied()
563 .unwrap_or(StringKey::new("table.row"));
564 let interaction = ctx.interact(
565 InteractDeclaration::new(row.id, row_rect, Sense::INTERACTIVE)
566 .focusable(false)
567 .active(selected)
568 .a11y(
569 AccessNode::new(Role::Row)
570 .with_label(label)
571 .with_selected(selected),
572 ),
573 );
574 let live_focused = ctx.is_focused(row.id);
575 if interaction.click() {
576 apply_table_selection(state, row.id, ctx.input.modifiers, mode);
577 state.focused = Some(row.id);
578 ctx.focus.request_focus(row.id);
579 if activated_row.is_none() {
580 *activated_row = Some(row.id);
581 }
582 }
583 let fill = list_row_fill(ctx, &interaction, state.selection.contains(&row.id));
584 let mut paint = vec![WidgetPaint::Surface {
585 rect: row_rect,
586 fill,
587 border: None,
588 radius: ctx.theme().radius.none,
589 elevation: None,
590 }];
591 column_widths
592 .iter()
593 .zip(column_x.iter())
594 .zip(row.cells.iter())
595 .for_each(|((width, x), text)| {
596 paint.push(WidgetPaint::Label {
597 rect: LayoutRect::new(
598 LayoutPos::new(*x, LayoutPx::new(row_y)),
599 LayoutSize::new(*width, row_height),
600 ),
601 text: LabelText::Key(*text),
602 color: ctx.theme().colors.text_primary(),
603 role: ctx.theme().typography.body,
604 });
605 });
606 push_focus_ring(
607 ctx,
608 &mut paint,
609 row_rect,
610 ctx.theme().radius.none,
611 live_focused,
612 );
613 paint
614}
615
616fn row_idx_to_y(row_idx: usize, row_height: LayoutPx) -> f32 {
617 #[allow(
618 clippy::cast_precision_loss,
619 reason = "table row index fits f32 mantissa"
620 )]
621 let i = row_idx as f32;
622 i * row_height.value()
623}
624
625fn draw_header_cell(
626 ctx: &mut FrameCtx<'_>,
627 column: &TableColumn,
628 header_rect: LayoutRect,
629 state: &mut TableState,
630 sort_changed: &mut Option<TableSort>,
631 column_resized: &mut Option<(WidgetId, LayoutPx)>,
632) -> Vec<WidgetPaint> {
633 let mut paint = Vec::new();
634 let header_id = column.id;
635 let resize_id = column.id.child(WidgetKey::new("resize"));
636 let resize_rect = LayoutRect::new(
637 LayoutPos::new(
638 LayoutPx::new(
639 header_rect.origin.x.value() + header_rect.size.width.value() - RESIZE_HANDLE_PX,
640 ),
641 header_rect.origin.y,
642 ),
643 LayoutSize::new(LayoutPx::new(RESIZE_HANDLE_PX), header_rect.size.height),
644 );
645 let header_interaction = ctx.interact(
646 InteractDeclaration::new(header_id, header_rect, Sense::INTERACTIVE)
647 .focusable(false)
648 .a11y(AccessNode::new(Role::ColumnHeader).with_label(column.label)),
649 );
650 let resize_interaction = ctx.interact(
651 InteractDeclaration::new(resize_id, resize_rect, Sense::DRAGGABLE)
652 .focusable(false)
653 .a11y(
654 AccessNode::new(Role::Splitter).with_label(StringKey::new("table.column.resize")),
655 ),
656 );
657 if header_interaction.click() && column.sortable {
658 let next = match state.sort {
659 Some(prev) if prev.column == column.id => TableSort {
660 column: column.id,
661 direction: prev.direction.flip(),
662 },
663 _ => TableSort {
664 column: column.id,
665 direction: SortDirection::Ascending,
666 },
667 };
668 state.sort = Some(next);
669 if sort_changed.is_none() {
670 *sort_changed = Some(next);
671 }
672 }
673 if resize_interaction.drag_start() {
674 state.resizing = Some(ResizeAnchor {
675 column: column.id,
676 start_width: header_rect.size.width,
677 });
678 }
679 if let Some(anchor) = state.resizing
680 && anchor.column == column.id
681 && (resize_interaction.pressed() || resize_interaction.drag_start())
682 {
683 let new_width = LayoutPx::new(
684 (anchor.start_width.value() + resize_interaction.drag_delta.dx.value())
685 .max(column.min_width.value()),
686 );
687 state.column_widths.insert(column.id, new_width);
688 if column_resized.is_none() {
689 *column_resized = Some((column.id, new_width));
690 }
691 }
692 if resize_interaction.drag_release() {
693 state.resizing = None;
694 }
695 paint.push(WidgetPaint::Surface {
696 rect: header_rect,
697 fill: if header_interaction.hover() {
698 ctx.theme().colors.neutral.step(Step12::HOVER_BG)
699 } else {
700 ctx.theme().colors.neutral.step(Step12::SUBTLE_BG)
701 },
702 border: Some(Border {
703 width: StrokeWidth::HAIRLINE,
704 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
705 }),
706 radius: ctx.theme().radius.none,
707 elevation: None,
708 });
709 paint.push(WidgetPaint::Label {
710 rect: header_rect,
711 text: LabelText::Key(column.label),
712 color: ctx.theme().colors.text_primary(),
713 role: ctx.theme().typography.label,
714 });
715 if let Some(sort) = state.sort
716 && sort.column == column.id
717 {
718 paint.push(WidgetPaint::Mark {
719 rect: sort_indicator_rect(header_rect),
720 kind: match sort.direction {
721 SortDirection::Ascending => GlyphMark::SortAscending,
722 SortDirection::Descending => GlyphMark::SortDescending,
723 },
724 color: ctx.theme().colors.text_secondary(),
725 });
726 }
727 paint
728}
729
730fn sort_indicator_rect(header: LayoutRect) -> LayoutRect {
731 let size = 12.0;
732 let pad = (header.size.height.value() - size) / 2.0;
733 LayoutRect::new(
734 LayoutPos::new(
735 LayoutPx::new(header.origin.x.value() + header.size.width.value() - size - 8.0),
736 LayoutPx::new(header.origin.y.value() + pad),
737 ),
738 LayoutSize::new(LayoutPx::new(size), LayoutPx::new(size)),
739 )
740}
741
742fn apply_table_selection(
743 state: &mut TableState,
744 id: WidgetId,
745 modifiers: ModifierMask,
746 mode: ListSelectionMode,
747) {
748 match mode {
749 ListSelectionMode::Single => {
750 state.selection = BTreeSet::from([id]);
751 }
752 ListSelectionMode::Multi => {
753 if modifiers.contains(ModifierMask::CTRL) {
754 if !state.selection.insert(id) {
755 state.selection.remove(&id);
756 }
757 } else {
758 state.selection = BTreeSet::from([id]);
759 }
760 }
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use std::sync::Arc;
767
768 use super::{
769 LabelText, ListItem, ListView, ListViewState, SortDirection, Table, TableColumn, TableRow,
770 TableState, show_list_view, show_table,
771 };
772 use crate::focus::FocusManager;
773 use crate::frame::FrameCtx;
774 use crate::hit_test::{HitFrame, HitState, resolve};
775 use crate::hotkey::HotkeyTable;
776 use crate::input::{
777 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
778 PointerButtonMask, PointerSample,
779 };
780 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
781 use crate::strings::{StringKey, StringTable};
782 use crate::theme::Theme;
783 use crate::widget_id::{WidgetId, WidgetKey};
784
785 fn list_id() -> WidgetId {
786 WidgetId::ROOT.child(WidgetKey::new("list"))
787 }
788
789 fn list_items(count: u64) -> Vec<ListItem> {
790 (0..count)
791 .map(|i| ListItem {
792 id: list_id().child_indexed(WidgetKey::new("item"), i),
793 label: LabelText::Key(StringKey::new("list.item")),
794 })
795 .collect()
796 }
797
798 fn render_list(
799 items: &[ListItem],
800 state: &mut ListViewState,
801 focus: &mut FocusManager,
802 snap: &mut InputSnapshot,
803 prev: &HitState,
804 ) -> (super::ListViewResponse, HitState) {
805 let theme = Arc::new(Theme::light());
806 let table = HotkeyTable::new();
807 let mut hits = HitFrame::new();
808 let rect = LayoutRect::new(
809 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
810 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
811 );
812 let response = {
813 let mut shaper = bone_text::Shaper::new();
814 let mut a11y = crate::a11y::AccessTreeBuilder::new();
815 let mut ctx = FrameCtx::new(
816 theme,
817 snap,
818 focus,
819 &table,
820 StringTable::empty(),
821 &mut hits,
822 prev,
823 &mut a11y,
824 &mut shaper,
825 );
826 show_list_view(
827 &mut ctx,
828 ListView::new(list_id(), rect, StringKey::new("test.list"), items, state),
829 )
830 };
831 let next = resolve(prev, &hits, snap, focus.focused());
832 (response, next)
833 }
834
835 fn render_list_at(
836 items: &[ListItem],
837 state: &mut ListViewState,
838 scroll_y: f32,
839 pointer: Option<LayoutPos>,
840 ) -> (Vec<super::WidgetPaint>, bool) {
841 let theme = Arc::new(Theme::light());
842 let table = HotkeyTable::new();
843 let mut focus = FocusManager::new();
844 let mut hits = HitFrame::new();
845 let prev = HitState::new();
846 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
847 snap.scroll_y = scroll_y;
848 snap.pointer = pointer.map(PointerSample::new);
849 let rect = LayoutRect::new(
850 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
851 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
852 );
853 let scrollbar_id = list_id().child(WidgetKey::new("scrollbar"));
854 let mut shaper = bone_text::Shaper::new();
855 let mut a11y = crate::a11y::AccessTreeBuilder::new();
856 let paint = {
857 let mut ctx = FrameCtx::new(
858 theme,
859 &mut snap,
860 &mut focus,
861 &table,
862 StringTable::empty(),
863 &mut hits,
864 &prev,
865 &mut a11y,
866 &mut shaper,
867 );
868 show_list_view(
869 &mut ctx,
870 ListView::new(list_id(), rect, StringKey::new("test.list"), items, state),
871 )
872 .paint
873 };
874 (paint, a11y.contains(scrollbar_id))
875 }
876
877 fn count_labels(paint: &[super::WidgetPaint]) -> usize {
878 paint
879 .iter()
880 .filter(|p| matches!(p, super::WidgetPaint::Label { .. }))
881 .count()
882 }
883
884 #[test]
885 fn tall_list_culls_rows_and_shows_a_scrollbar() {
886 let items = list_items(40);
887 let mut state = ListViewState::default();
888 let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None);
889 assert!(has_bar, "an overflowing list shows a scrollbar");
890 assert_eq!(
891 count_labels(&paint),
892 18,
893 "only the rows that fit the 400 px viewport paint",
894 );
895 }
896
897 #[test]
898 fn list_that_fits_shows_no_scrollbar() {
899 let items = list_items(3);
900 let mut state = ListViewState::default();
901 let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None);
902 assert!(!has_bar, "a list that fits its viewport shows no scrollbar");
903 assert_eq!(count_labels(&paint), 3);
904 }
905
906 #[test]
907 fn wheel_over_the_list_scrolls_it() {
908 let items = list_items(40);
909 let mut state = ListViewState::default();
910 let _ = render_list_at(
911 &items,
912 &mut state,
913 100.0,
914 Some(LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(100.0))),
915 );
916 assert!(
917 state.scroll_offset.value() > 0.0,
918 "a wheel notch over the list advances the offset",
919 );
920 }
921
922 fn press(pos: LayoutPos) -> InputSnapshot {
923 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
924 s.pointer = Some(PointerSample::new(pos));
925 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
926 s
927 }
928
929 fn release(pos: LayoutPos) -> InputSnapshot {
930 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
931 s.pointer = Some(PointerSample::new(pos));
932 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
933 s
934 }
935
936 fn idle(pos: LayoutPos) -> InputSnapshot {
937 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
938 s.pointer = Some(PointerSample::new(pos));
939 s
940 }
941
942 #[test]
943 fn click_list_item_selects() {
944 let items = list_items(3);
945 let mut state = ListViewState::default();
946 let mut focus = FocusManager::new();
947 let mut prev = HitState::new();
948 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
949 let mut last: Option<super::ListViewResponse> = None;
950 [press(click_pos), release(click_pos), idle(click_pos)]
951 .into_iter()
952 .for_each(|mut snap| {
953 let (response, next) =
954 render_list(&items, &mut state, &mut focus, &mut snap, &prev);
955 last = Some(response);
956 prev = next;
957 });
958 let Some(response) = last else {
959 panic!("response missing")
960 };
961 assert_eq!(response.activated, Some(items[1].id));
962 assert!(state.selection.contains(&items[1].id));
963 }
964
965 #[test]
966 fn arrow_down_moves_focus_in_list() {
967 let items = list_items(3);
968 let mut state = ListViewState::default();
969 let mut focus = FocusManager::new();
970 focus.register_focusable(items[0].id);
971 focus.request_focus(items[0].id);
972 focus.end_frame();
973 let prev = HitState::new();
974 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
975 snap.keys_pressed.push(KeyEvent::new(
976 KeyCode::Named(NamedKey::ArrowDown),
977 ModifierMask::NONE,
978 ));
979 let _ = render_list(&items, &mut state, &mut focus, &mut snap, &prev);
980 assert_eq!(state.focused, Some(items[1].id));
981 }
982
983 #[test]
984 fn double_click_list_item_sets_opened() {
985 let items = list_items(3);
986 let mut state = ListViewState::default();
987 let mut focus = FocusManager::new();
988 let mut prev = HitState::new();
989 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
990 let mut last: Option<super::ListViewResponse> = None;
991 let frames = [
992 press(click_pos),
993 release(click_pos),
994 press(click_pos),
995 release(click_pos),
996 idle(click_pos),
997 ];
998 frames.into_iter().for_each(|mut snap| {
999 let (response, next) = render_list(&items, &mut state, &mut focus, &mut snap, &prev);
1000 last = Some(response);
1001 prev = next;
1002 });
1003 let Some(response) = last else {
1004 panic!("response missing")
1005 };
1006 assert_eq!(response.opened, Some(items[1].id));
1007 }
1008
1009 #[test]
1010 fn single_click_list_item_does_not_set_opened() {
1011 let items = list_items(3);
1012 let mut state = ListViewState::default();
1013 let mut focus = FocusManager::new();
1014 let mut prev = HitState::new();
1015 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
1016 let mut last: Option<super::ListViewResponse> = None;
1017 [press(click_pos), release(click_pos), idle(click_pos)]
1018 .into_iter()
1019 .for_each(|mut snap| {
1020 let (response, next) =
1021 render_list(&items, &mut state, &mut focus, &mut snap, &prev);
1022 last = Some(response);
1023 prev = next;
1024 });
1025 let Some(response) = last else {
1026 panic!("response missing")
1027 };
1028 assert!(response.opened.is_none());
1029 }
1030
1031 #[test]
1032 fn list_view_registers_single_tab_stop() {
1033 let items = list_items(4);
1034 let mut state = ListViewState::default();
1035 let mut focus = FocusManager::new();
1036 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1037 let prev = HitState::new();
1038 let _ = render_list(&items, &mut state, &mut focus, &mut snap, &prev);
1039 let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect();
1040 assert_eq!(stops, vec![items[0].id]);
1041 }
1042
1043 fn table_id() -> WidgetId {
1044 WidgetId::ROOT.child(WidgetKey::new("table"))
1045 }
1046
1047 fn columns() -> [TableColumn; 2] {
1048 [
1049 TableColumn::new(
1050 table_id().child(WidgetKey::new("name")),
1051 StringKey::new("col.name"),
1052 LayoutPx::new(120.0),
1053 ),
1054 TableColumn::new(
1055 table_id().child(WidgetKey::new("value")),
1056 StringKey::new("col.value"),
1057 LayoutPx::new(80.0),
1058 ),
1059 ]
1060 }
1061
1062 fn rows() -> Vec<TableRow<2>> {
1063 vec![
1064 TableRow {
1065 id: table_id().child_indexed(WidgetKey::new("row"), 0),
1066 cells: [
1067 StringKey::new("row.first"),
1068 StringKey::new("row.first.value"),
1069 ],
1070 },
1071 TableRow {
1072 id: table_id().child_indexed(WidgetKey::new("row"), 1),
1073 cells: [
1074 StringKey::new("row.second"),
1075 StringKey::new("row.second.value"),
1076 ],
1077 },
1078 ]
1079 }
1080
1081 fn render_table(
1082 cols: &[TableColumn; 2],
1083 rows_data: &[TableRow<2>],
1084 state: &mut TableState,
1085 focus: &mut FocusManager,
1086 snap: &mut InputSnapshot,
1087 prev: &HitState,
1088 ) -> (super::TableResponse, HitState) {
1089 let theme = Arc::new(Theme::light());
1090 let table_keys = HotkeyTable::new();
1091 let mut hits = HitFrame::new();
1092 let rect = LayoutRect::new(
1093 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1094 LayoutSize::new(LayoutPx::new(220.0), LayoutPx::new(200.0)),
1095 );
1096 let response = {
1097 let mut shaper = bone_text::Shaper::new();
1098 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1099 let mut ctx = FrameCtx::new(
1100 theme,
1101 snap,
1102 focus,
1103 &table_keys,
1104 StringTable::empty(),
1105 &mut hits,
1106 prev,
1107 &mut a11y,
1108 &mut shaper,
1109 );
1110 show_table(
1111 &mut ctx,
1112 Table::new(
1113 table_id(),
1114 rect,
1115 StringKey::new("test.table"),
1116 cols,
1117 rows_data,
1118 state,
1119 ),
1120 )
1121 };
1122 let next = resolve(prev, &hits, snap, focus.focused());
1123 (response, next)
1124 }
1125
1126 #[test]
1127 fn click_header_sets_sort_to_ascending_then_descending() {
1128 let cols = columns();
1129 let rows_data = rows();
1130 let mut state = TableState::default();
1131 let mut focus = FocusManager::new();
1132 let mut prev = HitState::new();
1133 let header_pos = LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(12.0));
1134 let mut last: Option<super::TableResponse> = None;
1135 [press(header_pos), release(header_pos), idle(header_pos)]
1136 .into_iter()
1137 .for_each(|mut snap| {
1138 let (response, next) =
1139 render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
1140 last = Some(response);
1141 prev = next;
1142 });
1143 assert_eq!(
1144 state.sort.map(|s| s.direction),
1145 Some(SortDirection::Ascending),
1146 "first click ascending",
1147 );
1148 prev = HitState::new();
1149 last = None;
1150 [press(header_pos), release(header_pos), idle(header_pos)]
1151 .into_iter()
1152 .for_each(|mut snap| {
1153 let (response, next) =
1154 render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
1155 last = Some(response);
1156 prev = next;
1157 });
1158 assert_eq!(
1159 state.sort.map(|s| s.direction),
1160 Some(SortDirection::Descending),
1161 "second click descending",
1162 );
1163 }
1164
1165 #[test]
1166 fn click_row_activates_and_selects() {
1167 let cols = columns();
1168 let rows_data = rows();
1169 let mut state = TableState::default();
1170 let mut focus = FocusManager::new();
1171 let mut prev = HitState::new();
1172 let row_pos = LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(36.0));
1173 let mut last: Option<super::TableResponse> = None;
1174 [press(row_pos), release(row_pos), idle(row_pos)]
1175 .into_iter()
1176 .for_each(|mut snap| {
1177 let (response, next) =
1178 render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
1179 last = Some(response);
1180 prev = next;
1181 });
1182 let Some(response) = last else {
1183 panic!("response missing")
1184 };
1185 assert_eq!(response.activated_row, Some(rows_data[0].id));
1186 assert!(state.selection.contains(&rows_data[0].id));
1187 }
1188
1189 #[test]
1190 fn table_registers_single_row_tab_stop() {
1191 let cols = columns();
1192 let rows_data = rows();
1193 let mut state = TableState::default();
1194 let mut focus = FocusManager::new();
1195 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1196 let prev = HitState::new();
1197 let _ = render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
1198 let row_stops: Vec<WidgetId> = focus
1199 .tab_stops()
1200 .iter()
1201 .map(|(id, _)| *id)
1202 .filter(|id| rows_data.iter().any(|r| r.id == *id))
1203 .collect();
1204 assert_eq!(row_stops, vec![rows_data[0].id]);
1205 }
1206}