Another project
1
fork

Configure Feed

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

feat(ui): table and list view widgets

Lewis: May this revision serve well! <lu5a@proton.me>

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