Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 38 kB View raw
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}