Another project
0

Configure Feed

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

at main 69 kB View raw
1use std::collections::BTreeSet; 2 3use crate::a11y::{AccessNode, Role}; 4use crate::frame::{FrameCtx, InteractDeclaration}; 5use crate::hit_test::Sense; 6use crate::input::{KeyCode, ModifierMask, NamedKey, PointerButton}; 7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 8use crate::strings::StringKey; 9use crate::theme::{Color, Step12}; 10use crate::widget_id::{WidgetId, WidgetKey}; 11 12use bone_types::IconId; 13 14use super::keys::{TakeKey, take_key}; 15use super::paint::{GlyphMark, HorizontalAlign, IconSlot, IconTint, LabelText, WidgetPaint}; 16use super::scrollbar::{row_window, wheel_scroll, window_scrollbar}; 17use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 18use super::visuals::push_focus_ring; 19 20#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 21pub enum TreeBadge { 22 RebuildNeeded, 23 Warning, 24 Error, 25} 26 27#[derive(Clone, Debug, PartialEq)] 28pub struct TreeNode { 29 pub id: WidgetId, 30 pub label: LabelText, 31 pub children: Vec<TreeNode>, 32 pub disabled: bool, 33 pub icon: Option<IconId>, 34 pub badge: Option<TreeBadge>, 35} 36 37impl TreeNode { 38 #[must_use] 39 pub fn leaf(id: WidgetId, label: StringKey) -> Self { 40 Self::with_label(id, LabelText::Key(label), Vec::new()) 41 } 42 43 #[must_use] 44 pub fn leaf_owned(id: WidgetId, label: String) -> Self { 45 Self::with_label(id, LabelText::Owned(label), Vec::new()) 46 } 47 48 #[must_use] 49 pub fn parent(id: WidgetId, label: StringKey, children: Vec<TreeNode>) -> Self { 50 Self::with_label(id, LabelText::Key(label), children) 51 } 52 53 #[must_use] 54 pub fn parent_owned(id: WidgetId, label: String, children: Vec<TreeNode>) -> Self { 55 Self::with_label(id, LabelText::Owned(label), children) 56 } 57 58 #[must_use] 59 fn with_label(id: WidgetId, label: LabelText, children: Vec<TreeNode>) -> Self { 60 Self { 61 id, 62 label, 63 children, 64 disabled: false, 65 icon: None, 66 badge: None, 67 } 68 } 69 70 #[must_use] 71 pub fn disabled(mut self, disabled: bool) -> Self { 72 self.disabled = disabled; 73 self 74 } 75 76 #[must_use] 77 pub fn with_icon(mut self, icon: IconId) -> Self { 78 self.icon = Some(icon); 79 self 80 } 81 82 #[must_use] 83 pub fn with_badge(mut self, badge: Option<TreeBadge>) -> Self { 84 self.badge = badge; 85 self 86 } 87 88 #[must_use] 89 pub fn has_children(&self) -> bool { 90 !self.children.is_empty() 91 } 92} 93 94#[derive(Copy, Clone, Debug, PartialEq, Eq)] 95pub enum TreeSelectionMode { 96 Single, 97 Multi, 98} 99 100#[derive(Copy, Clone, Debug, PartialEq, Eq)] 101pub enum DropPlacement { 102 Before, 103 After, 104} 105 106#[derive(Copy, Clone, Debug, PartialEq, Eq)] 107pub struct DropTarget { 108 pub anchor: WidgetId, 109 pub placement: DropPlacement, 110} 111 112#[derive(Copy, Clone, Debug, PartialEq)] 113pub struct ContextMenuRequest { 114 pub target: WidgetId, 115 pub at: LayoutPos, 116} 117 118#[derive(Copy, Clone, Debug, PartialEq, Eq)] 119pub enum RollbackTarget { 120 AtEnd, 121 Above(WidgetId), 122} 123 124#[derive(Copy, Clone, Debug, PartialEq, Eq)] 125pub struct RollbackBar<'a> { 126 pub stops: &'a [WidgetId], 127 pub marker: RollbackTarget, 128} 129 130#[derive(Clone, Debug, Default, PartialEq)] 131pub struct TreeViewState { 132 pub expanded: BTreeSet<WidgetId>, 133 pub selection: BTreeSet<WidgetId>, 134 pub focused: Option<WidgetId>, 135 pub renaming: Option<WidgetId>, 136 pub rename_buffer: TextInputState, 137 pub clipboard: MemoryClipboard, 138 pub drag_source: Option<WidgetId>, 139 pub drop_target: Option<DropTarget>, 140 pub pending_rename: Option<PendingRename>, 141 pub scroll_offset: LayoutPx, 142} 143 144#[derive(Copy, Clone, Debug, PartialEq, Eq)] 145pub struct PendingRename { 146 pub id: WidgetId, 147 pub at: crate::input::FrameInstant, 148} 149 150#[derive(Debug, PartialEq)] 151pub struct TreeView<'a, 'state> { 152 pub id: WidgetId, 153 pub rect: LayoutRect, 154 pub label: StringKey, 155 pub roots: &'a [TreeNode], 156 pub state: &'state mut TreeViewState, 157 pub mode: TreeSelectionMode, 158 pub row_height: LayoutPx, 159 pub indent_step: LayoutPx, 160 pub renamable: &'a [WidgetId], 161 pub illegal_drop: bool, 162 pub rollback: Option<RollbackBar<'a>>, 163} 164 165impl<'a, 'state> TreeView<'a, 'state> { 166 #[must_use] 167 pub const fn new( 168 id: WidgetId, 169 rect: LayoutRect, 170 label: StringKey, 171 roots: &'a [TreeNode], 172 state: &'state mut TreeViewState, 173 ) -> Self { 174 Self { 175 id, 176 rect, 177 label, 178 roots, 179 state, 180 mode: TreeSelectionMode::Single, 181 row_height: LayoutPx::new(20.0), 182 indent_step: LayoutPx::new(16.0), 183 renamable: &[], 184 illegal_drop: false, 185 rollback: None, 186 } 187 } 188 189 #[must_use] 190 pub const fn mode(self, mode: TreeSelectionMode) -> Self { 191 Self { mode, ..self } 192 } 193 194 #[must_use] 195 pub const fn renamable(self, renamable: &'a [WidgetId]) -> Self { 196 Self { renamable, ..self } 197 } 198 199 #[must_use] 200 pub const fn illegal_drop(self, illegal_drop: bool) -> Self { 201 Self { 202 illegal_drop, 203 ..self 204 } 205 } 206 207 #[must_use] 208 pub const fn rollback(self, rollback: Option<RollbackBar<'a>>) -> Self { 209 Self { rollback, ..self } 210 } 211} 212 213#[derive(Clone, Debug, PartialEq, Eq)] 214pub struct RenameCommit { 215 pub id: WidgetId, 216 pub text: String, 217} 218 219#[derive(Clone, Debug, PartialEq)] 220pub struct TreeViewResponse { 221 pub activated: Option<WidgetId>, 222 pub double_activated: Option<WidgetId>, 223 pub rename_committed: Option<RenameCommit>, 224 pub rename_cancelled: Option<WidgetId>, 225 pub drop_committed: Option<(WidgetId, DropTarget)>, 226 pub context_menu: Option<ContextMenuRequest>, 227 pub rollback_moved: Option<RollbackTarget>, 228 pub paint: Vec<WidgetPaint>, 229} 230 231#[must_use] 232pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeViewResponse { 233 let TreeView { 234 id, 235 rect, 236 label, 237 roots, 238 state, 239 mode, 240 row_height, 241 indent_step, 242 renamable, 243 illegal_drop, 244 rollback, 245 } = view; 246 ctx.a11y 247 .push(id, rect, AccessNode::new(Role::Tree).with_label(label)); 248 let mut paint = vec![WidgetPaint::Surface { 249 rect, 250 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0), 251 border: None, 252 radius: ctx.theme().radius.none, 253 elevation: None, 254 }]; 255 let visible: Vec<VisibleRow> = flatten(roots, &state.expanded, 0); 256 let requested = wheel_scroll(ctx, rect, state.scroll_offset); 257 let window = row_window(rect, visible.len(), row_height, requested); 258 state.scroll_offset = window.offset; 259 let content_rect = window.content_rect; 260 let entry_id = state 261 .focused 262 .filter(|f| visible.iter().any(|r| r.id == *f)) 263 .or_else(|| visible.first().map(|r| r.id)); 264 if let Some(id) = entry_id { 265 ctx.focus.register_tab_stop(id); 266 } 267 let row_rect_of = |idx: usize| row_rect_at(content_rect, idx - window.first_row, row_height); 268 let (row_paint, interactions) = paint_tree_rows( 269 ctx, 270 &visible, 271 state, 272 renamable, 273 RowFrame { 274 content_rect, 275 row_height, 276 indent_step, 277 first_row: window.first_row, 278 last_row: window.last_row, 279 mode, 280 illegal_drop, 281 }, 282 ); 283 let RowInteractions { 284 mut activated, 285 double_activated, 286 drop_committed, 287 context_menu, 288 } = interactions; 289 paint.extend(row_paint); 290 let rollback_moved = rollback.and_then(|bar| { 291 let (bar_paint, moved) = render_rollback_bar( 292 ctx, 293 id, 294 bar, 295 &visible, 296 content_rect, 297 window.first_row, 298 row_height, 299 ); 300 paint.extend(bar_paint); 301 moved 302 }); 303 let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset); 304 state.scroll_offset = next_offset; 305 paint.extend(bar_paint); 306 let pending_row_rect = state.pending_rename.and_then(|pending| { 307 visible 308 .iter() 309 .position(|row| row.id == pending.id) 310 .filter(|idx| (window.first_row..window.last_row).contains(idx)) 311 .map(row_rect_of) 312 }); 313 commit_pending_rename(ctx, &visible, state, renamable, pending_row_rect); 314 let (rename_committed, rename_cancelled) = resolve_rename(ctx, state); 315 if state.renaming.is_none() { 316 handle_keyboard(ctx, &visible, state, renamable, &mut activated); 317 } 318 TreeViewResponse { 319 activated, 320 double_activated, 321 rename_committed, 322 rename_cancelled, 323 drop_committed, 324 context_menu, 325 rollback_moved, 326 paint, 327 } 328} 329 330#[derive(Copy, Clone)] 331struct RowFrame { 332 content_rect: LayoutRect, 333 row_height: LayoutPx, 334 indent_step: LayoutPx, 335 first_row: usize, 336 last_row: usize, 337 mode: TreeSelectionMode, 338 illegal_drop: bool, 339} 340 341struct RowInteractions { 342 activated: Option<WidgetId>, 343 double_activated: Option<WidgetId>, 344 drop_committed: Option<(WidgetId, DropTarget)>, 345 context_menu: Option<ContextMenuRequest>, 346} 347 348fn paint_tree_rows( 349 ctx: &mut FrameCtx<'_>, 350 visible: &[VisibleRow], 351 state: &mut TreeViewState, 352 renamable: &[WidgetId], 353 frame: RowFrame, 354) -> (Vec<WidgetPaint>, RowInteractions) { 355 let mut out = RowInteractions { 356 activated: None, 357 double_activated: None, 358 drop_committed: None, 359 context_menu: None, 360 }; 361 let paint = visible 362 .iter() 363 .enumerate() 364 .skip(frame.first_row) 365 .take(frame.last_row - frame.first_row) 366 .flat_map(|(idx, row)| { 367 let row_rect = row_rect_at(frame.content_rect, idx - frame.first_row, frame.row_height); 368 draw_row( 369 ctx, 370 RowDrawArgs { 371 row, 372 row_rect, 373 indent_step: frame.indent_step, 374 state, 375 mode: frame.mode, 376 renamable, 377 illegal_drop: frame.illegal_drop, 378 }, 379 &mut RowOutcomes { 380 activated: &mut out.activated, 381 double_activated: &mut out.double_activated, 382 drop_committed: &mut out.drop_committed, 383 context_menu: &mut out.context_menu, 384 }, 385 ) 386 }) 387 .collect(); 388 (paint, out) 389} 390 391fn rollback_boundaries( 392 bar: RollbackBar<'_>, 393 visible: &[VisibleRow], 394 content_rect: LayoutRect, 395 first_row: usize, 396 row_height: LayoutPx, 397) -> Vec<(f32, RollbackTarget)> { 398 let row_top = |index: usize| { 399 row_rect_at(content_rect, index.saturating_sub(first_row), row_height) 400 .origin 401 .y 402 .value() 403 }; 404 let stops: Vec<(usize, WidgetId)> = bar 405 .stops 406 .iter() 407 .filter_map(|stop| { 408 visible 409 .iter() 410 .position(|row| row.id == *stop) 411 .map(|index| (index, *stop)) 412 }) 413 .collect(); 414 let above = stops 415 .iter() 416 .map(|(index, id)| (row_top(*index), RollbackTarget::Above(*id))); 417 let at_end = stops 418 .last() 419 .map(|(index, _)| (row_top(*index) + row_height.value(), RollbackTarget::AtEnd)); 420 above.chain(at_end).collect() 421} 422 423fn nearest_boundary( 424 boundaries: &[(f32, RollbackTarget)], 425 pointer_y: f32, 426) -> Option<RollbackTarget> { 427 boundaries 428 .iter() 429 .min_by(|a, b| (a.0 - pointer_y).abs().total_cmp(&(b.0 - pointer_y).abs())) 430 .map(|(_, target)| *target) 431} 432 433fn render_rollback_bar( 434 ctx: &mut FrameCtx<'_>, 435 tree_id: WidgetId, 436 bar: RollbackBar<'_>, 437 visible: &[VisibleRow], 438 content_rect: LayoutRect, 439 first_row: usize, 440 row_height: LayoutPx, 441) -> (Vec<WidgetPaint>, Option<RollbackTarget>) { 442 let boundaries = rollback_boundaries(bar, visible, content_rect, first_row, row_height); 443 let Some(&(current_y, _)) = boundaries.iter().find(|(_, target)| *target == bar.marker) else { 444 return (Vec::new(), None); 445 }; 446 let bar_id = tree_id.child(WidgetKey::new("rollback_bar")); 447 let hit_rect = LayoutRect::new( 448 LayoutPos::new(content_rect.origin.x, LayoutPx::new(current_y - 3.0)), 449 LayoutSize::new(content_rect.size.width, LayoutPx::new(6.0)), 450 ); 451 let interaction = 452 ctx.interact(InteractDeclaration::new(bar_id, hit_rect, Sense::DRAGGABLE).focusable(false)); 453 let dragging = interaction.drag_button == Some(PointerButton::Primary); 454 let pointer_target = ctx 455 .input 456 .pointer 457 .and_then(|sample| nearest_boundary(&boundaries, sample.position.y.value())); 458 let moved = interaction 459 .drag_release() 460 .then_some(pointer_target) 461 .flatten() 462 .filter(|target| *target != bar.marker); 463 let line_rect = |y: f32| { 464 LayoutRect::new( 465 LayoutPos::new(content_rect.origin.x, LayoutPx::new(y - 1.0)), 466 LayoutSize::new(content_rect.size.width, LayoutPx::new(2.0)), 467 ) 468 }; 469 let mut paint = vec![WidgetPaint::Surface { 470 rect: line_rect(current_y), 471 fill: ctx.theme().colors.accent.step(Step12::SOLID), 472 border: None, 473 radius: ctx.theme().radius.none, 474 elevation: None, 475 }]; 476 let preview_y = dragging 477 .then_some(pointer_target) 478 .flatten() 479 .and_then(|target| { 480 boundaries 481 .iter() 482 .find(|(_, b)| *b == target) 483 .map(|(y, _)| *y) 484 }) 485 .filter(|y| (*y - current_y).abs() > f32::EPSILON); 486 if let Some(y) = preview_y { 487 paint.push(WidgetPaint::Surface { 488 rect: line_rect(y), 489 fill: ctx.theme().colors.accent.step(Step12::HOVER_BORDER), 490 border: None, 491 radius: ctx.theme().radius.none, 492 elevation: None, 493 }); 494 } 495 (paint, moved) 496} 497 498#[derive(Clone, Debug, PartialEq)] 499struct VisibleRow { 500 id: WidgetId, 501 label: LabelText, 502 depth: usize, 503 has_children: bool, 504 disabled: bool, 505 icon: Option<IconId>, 506 badge: Option<TreeBadge>, 507} 508 509fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> { 510 roots 511 .iter() 512 .flat_map(|node| { 513 let row = VisibleRow { 514 id: node.id, 515 label: node.label.clone(), 516 depth, 517 has_children: node.has_children(), 518 disabled: node.disabled, 519 icon: node.icon, 520 badge: node.badge, 521 }; 522 let children = if expanded.contains(&node.id) { 523 flatten(&node.children, expanded, depth + 1) 524 } else { 525 Vec::new() 526 }; 527 std::iter::once(row).chain(children) 528 }) 529 .collect() 530} 531 532fn resolve_rename( 533 ctx: &mut FrameCtx<'_>, 534 state: &mut TreeViewState, 535) -> (Option<RenameCommit>, Option<WidgetId>) { 536 let committed = if let Some(id) = state.renaming 537 && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 538 { 539 let text = state.rename_buffer.text.clone(); 540 state.renaming = None; 541 state.rename_buffer = TextInputState::default(); 542 Some(RenameCommit { id, text }) 543 } else { 544 None 545 }; 546 let cancelled = if let Some(id) = state.renaming 547 && take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some() 548 { 549 state.renaming = None; 550 state.rename_buffer = TextInputState::default(); 551 Some(id) 552 } else { 553 None 554 }; 555 (committed, cancelled) 556} 557 558fn row_rect_at(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { 559 #[allow( 560 clippy::cast_precision_loss, 561 reason = "tree row index fits f32 mantissa" 562 )] 563 let i = idx as f32; 564 LayoutRect::new( 565 LayoutPos::new( 566 view.origin.x, 567 LayoutPx::new(view.origin.y.value() + i * row_height.value()), 568 ), 569 LayoutSize::new(view.size.width, row_height), 570 ) 571} 572 573struct RowDrawArgs<'a> { 574 row: &'a VisibleRow, 575 row_rect: LayoutRect, 576 indent_step: LayoutPx, 577 state: &'a mut TreeViewState, 578 mode: TreeSelectionMode, 579 renamable: &'a [WidgetId], 580 illegal_drop: bool, 581} 582 583fn draw_row( 584 ctx: &mut FrameCtx<'_>, 585 args: RowDrawArgs<'_>, 586 outcomes: &mut RowOutcomes<'_>, 587) -> Vec<WidgetPaint> { 588 let RowDrawArgs { 589 row, 590 row_rect, 591 indent_step, 592 state, 593 mode, 594 renamable, 595 illegal_drop, 596 } = args; 597 #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] 598 let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); 599 let disclosure_icon_rect = disclosure_icon_rect_at(row_rect, indent); 600 let disclosure_hit_rect = disclosure_hit_rect_at(row_rect, indent); 601 let label_rect = label_rect_at(row_rect, indent); 602 let selected = state.selection.contains(&row.id); 603 let expanded = state.expanded.contains(&row.id); 604 let interaction = ctx.interact( 605 InteractDeclaration::new(row.id, row_rect, Sense::DRAGGABLE) 606 .focusable(false) 607 .active(selected) 608 .a11y({ 609 let node = AccessNode::new(Role::TreeItem) 610 .with_label_text(row.label.clone()) 611 .with_selected(selected); 612 if row.has_children { 613 node.with_expanded(expanded) 614 } else { 615 node 616 } 617 }), 618 ); 619 let live_focused = ctx.is_focused(row.id); 620 apply_row_interaction( 621 ctx, 622 RowInteractionArgs { 623 row, 624 row_rect, 625 interaction: &interaction, 626 state, 627 mode, 628 renamable, 629 }, 630 outcomes, 631 ); 632 let mut paint = vec![WidgetPaint::Surface { 633 rect: row_rect, 634 fill: row_fill(ctx, &interaction, state.selection.contains(&row.id)), 635 border: None, 636 radius: ctx.theme().radius.none, 637 elevation: None, 638 }]; 639 if row.has_children { 640 paint.extend(draw_disclosure( 641 ctx, 642 row, 643 disclosure_icon_rect, 644 disclosure_hit_rect, 645 state, 646 row.disabled, 647 )); 648 } 649 let content_rect = badge_content_rect(label_rect, row.badge.is_some()); 650 if state.renaming == Some(row.id) { 651 paint.extend(draw_rename_editor( 652 ctx, 653 row.id, 654 &row.label, 655 content_rect, 656 state, 657 )); 658 } else { 659 paint.extend(label_with_glyph_paint(ctx, row, content_rect)); 660 } 661 if let Some(badge) = row.badge { 662 paint.push(badge_dot_paint(ctx, badge, label_rect)); 663 } 664 push_focus_ring( 665 ctx, 666 &mut paint, 667 row_rect, 668 ctx.theme().radius.none, 669 live_focused, 670 ); 671 if let Some(target) = state.drop_target 672 && target.anchor == row.id 673 { 674 let fill = if illegal_drop { 675 ctx.theme().colors.danger.step(Step12::SOLID) 676 } else { 677 ctx.theme().colors.accent.step(Step12::SOLID) 678 }; 679 paint.push(WidgetPaint::Surface { 680 rect: drop_indicator_rect(row_rect, target.placement), 681 fill, 682 border: None, 683 radius: ctx.theme().radius.none, 684 elevation: None, 685 }); 686 } 687 paint 688} 689 690const BADGE_GUTTER_PX: f32 = 11.0; 691 692fn badge_content_rect(label_rect: LayoutRect, has_badge: bool) -> LayoutRect { 693 if !has_badge { 694 return label_rect; 695 } 696 LayoutRect::new( 697 LayoutPos::new( 698 LayoutPx::new(label_rect.origin.x.value() + BADGE_GUTTER_PX), 699 label_rect.origin.y, 700 ), 701 LayoutSize::new( 702 LayoutPx::saturating_nonneg(label_rect.size.width.value() - BADGE_GUTTER_PX), 703 label_rect.size.height, 704 ), 705 ) 706} 707 708fn badge_dot_paint(ctx: &FrameCtx<'_>, badge: TreeBadge, label_rect: LayoutRect) -> WidgetPaint { 709 const DOT_PX: f32 = 7.0; 710 let rect = LayoutRect::new( 711 LayoutPos::new( 712 LayoutPx::new(label_rect.origin.x.value() + (BADGE_GUTTER_PX - DOT_PX) / 2.0), 713 LayoutPx::new( 714 label_rect.origin.y.value() + (label_rect.size.height.value() - DOT_PX) / 2.0, 715 ), 716 ), 717 LayoutSize::new(LayoutPx::new(DOT_PX), LayoutPx::new(DOT_PX)), 718 ); 719 let fill = match badge { 720 TreeBadge::Error => ctx.theme().colors.danger.step(Step12::SOLID), 721 TreeBadge::Warning => ctx.theme().colors.warning.step(Step12::SOLID), 722 TreeBadge::RebuildNeeded => ctx.theme().colors.accent.step(Step12::SOLID), 723 }; 724 WidgetPaint::Surface { 725 rect, 726 fill, 727 border: None, 728 radius: ctx.theme().radius.sm, 729 elevation: None, 730 } 731} 732 733struct RowOutcomes<'a> { 734 activated: &'a mut Option<WidgetId>, 735 double_activated: &'a mut Option<WidgetId>, 736 drop_committed: &'a mut Option<(WidgetId, DropTarget)>, 737 context_menu: &'a mut Option<ContextMenuRequest>, 738} 739 740struct RowInteractionArgs<'a> { 741 row: &'a VisibleRow, 742 row_rect: LayoutRect, 743 interaction: &'a crate::hit_test::Interaction, 744 state: &'a mut TreeViewState, 745 mode: TreeSelectionMode, 746 renamable: &'a [WidgetId], 747} 748 749fn commit_pending_rename( 750 ctx: &mut FrameCtx<'_>, 751 visible: &[VisibleRow], 752 state: &mut TreeViewState, 753 renamable: &[WidgetId], 754 pending_row_rect: Option<LayoutRect>, 755) { 756 let Some(pending) = state.pending_rename else { 757 return; 758 }; 759 if state.renaming.is_some() || !renamable.contains(&pending.id) { 760 state.pending_rename = None; 761 return; 762 } 763 let pressed_off_row = ctx.input.buttons_pressed.contains(PointerButton::Primary) 764 && !pending_row_rect 765 .zip(ctx.input.pointer.map(|sample| sample.position)) 766 .is_some_and(|(row, cursor)| row.contains(cursor)); 767 if pressed_off_row && pending.at != ctx.input.frame { 768 state.pending_rename = None; 769 return; 770 } 771 let Some(row) = visible.iter().find(|r| r.id == pending.id) else { 772 state.pending_rename = None; 773 return; 774 }; 775 let elapsed = ctx.input.frame.since(pending.at); 776 if elapsed < ctx.input.double_click_window.duration() { 777 return; 778 } 779 let text = match &row.label { 780 LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 781 LabelText::Owned(s) => s.clone(), 782 }; 783 state.renaming = Some(pending.id); 784 state.rename_buffer = TextInputState::from_text(&text); 785 state.pending_rename = None; 786} 787 788fn apply_row_interaction( 789 ctx: &mut FrameCtx<'_>, 790 args: RowInteractionArgs<'_>, 791 outcomes: &mut RowOutcomes<'_>, 792) { 793 let RowInteractionArgs { 794 row, 795 row_rect, 796 interaction, 797 state, 798 mode, 799 renamable, 800 } = args; 801 let RowOutcomes { 802 activated, 803 double_activated, 804 drop_committed, 805 context_menu, 806 } = outcomes; 807 if row.disabled { 808 return; 809 } 810 let secondary = interaction.click_button == Some(PointerButton::Secondary); 811 if secondary && interaction.click() { 812 if !state.selection.contains(&row.id) { 813 update_selection(state, row.id, ModifierMask::NONE, mode); 814 } 815 state.focused = Some(row.id); 816 ctx.focus.request_focus(row.id); 817 state.pending_rename = None; 818 if context_menu.is_none() 819 && let Some(at) = ctx.input.pointer.map(|sample| sample.position) 820 { 821 **context_menu = Some(ContextMenuRequest { target: row.id, at }); 822 } 823 return; 824 } 825 if interaction.click() { 826 let was_selected = state.selection.contains(&row.id); 827 let is_double = interaction.double_click(); 828 update_selection(state, row.id, ctx.input.modifiers, mode); 829 if activated.is_none() { 830 **activated = Some(row.id); 831 } 832 state.focused = Some(row.id); 833 ctx.focus.request_focus(row.id); 834 let row_pending = state.pending_rename.is_some_and(|p| p.id == row.id); 835 if !is_double && was_selected && state.renaming.is_none() && renamable.contains(&row.id) { 836 if !row_pending { 837 state.pending_rename = Some(PendingRename { 838 id: row.id, 839 at: ctx.input.frame, 840 }); 841 } 842 } else if is_double || (!was_selected && row_pending) { 843 state.pending_rename = None; 844 } 845 } 846 if interaction.double_click() { 847 if state.pending_rename.is_some_and(|p| p.id == row.id) { 848 state.pending_rename = None; 849 } 850 if double_activated.is_none() { 851 **double_activated = Some(row.id); 852 } 853 } 854 if interaction.drag_start() { 855 state.drag_source = Some(row.id); 856 } 857 if interaction.drag_release() 858 && let Some(src) = state.drag_source 859 && let Some(target) = state.drop_target 860 { 861 if drop_committed.is_none() && src != target.anchor { 862 **drop_committed = Some((src, target)); 863 } 864 state.drag_source = None; 865 state.drop_target = None; 866 } 867 if state.drag_source.is_some() 868 && interaction.hover() 869 && let Some(pointer) = ctx.input.pointer 870 { 871 state.drop_target = Some(DropTarget { 872 anchor: row.id, 873 placement: placement_from_pointer(pointer.position, row_rect), 874 }); 875 } 876} 877 878fn draw_disclosure( 879 ctx: &mut FrameCtx<'_>, 880 row: &VisibleRow, 881 icon_rect: LayoutRect, 882 hit_rect: LayoutRect, 883 state: &mut TreeViewState, 884 disabled: bool, 885) -> Vec<WidgetPaint> { 886 let disclosure_id = row.id.child(WidgetKey::new("disclosure")); 887 let sense = if disabled { 888 Sense::HOVER 889 } else { 890 Sense::INTERACTIVE 891 }; 892 let disclosure_interaction = ctx.interact( 893 InteractDeclaration::new(disclosure_id, hit_rect, sense) 894 .disabled(disabled) 895 .a11y( 896 AccessNode::new(Role::DisclosureTriangle) 897 .with_label_text(row.label.clone()) 898 .with_expanded(state.expanded.contains(&row.id)) 899 .with_disabled(disabled), 900 ), 901 ); 902 if !disabled && disclosure_interaction.click() { 903 toggle_expanded(state, row.id); 904 } 905 vec![WidgetPaint::Mark { 906 rect: icon_rect, 907 kind: if state.expanded.contains(&row.id) { 908 GlyphMark::DisclosureOpen 909 } else { 910 GlyphMark::DisclosureClosed 911 }, 912 color: ctx.theme().colors.text_secondary(), 913 }] 914} 915 916const TREE_GLYPH_COLUMN_PX: f32 = 18.0; 917const TREE_ICON_PX: f32 = 14.0; 918 919fn label_with_glyph_paint( 920 ctx: &FrameCtx<'_>, 921 row: &VisibleRow, 922 label_rect: LayoutRect, 923) -> Vec<WidgetPaint> { 924 let color = if row.disabled { 925 ctx.theme().colors.text_disabled() 926 } else { 927 ctx.theme().colors.text_primary() 928 }; 929 let glyph_paint = row.icon.map(|icon| { 930 IconSlot::new(icon, LayoutPx::new(TREE_ICON_PX)).paint_in( 931 glyph_slot_rect(label_rect), 932 IconTint::from_disabled(row.disabled), 933 ) 934 }); 935 let text_rect = if row.icon.is_some() { 936 text_after_glyph_rect(label_rect) 937 } else { 938 label_rect 939 }; 940 glyph_paint 941 .into_iter() 942 .chain(core::iter::once(WidgetPaint::AlignedLabel { 943 rect: text_rect, 944 text: row.label.clone(), 945 color, 946 role: ctx.theme().typography.label, 947 align: HorizontalAlign::Start, 948 })) 949 .collect() 950} 951 952fn glyph_slot_rect(label_rect: LayoutRect) -> LayoutRect { 953 LayoutRect::new( 954 label_rect.origin, 955 LayoutSize::new(LayoutPx::new(TREE_GLYPH_COLUMN_PX), label_rect.size.height), 956 ) 957} 958 959fn text_after_glyph_rect(label_rect: LayoutRect) -> LayoutRect { 960 LayoutRect::new( 961 LayoutPos::new( 962 LayoutPx::new(label_rect.origin.x.value() + TREE_GLYPH_COLUMN_PX), 963 label_rect.origin.y, 964 ), 965 LayoutSize::new( 966 LayoutPx::saturating_nonneg(label_rect.size.width.value() - TREE_GLYPH_COLUMN_PX), 967 label_rect.size.height, 968 ), 969 ) 970} 971 972const RENAME_FALLBACK_PLACEHOLDER: StringKey = StringKey::new("tree.rename.placeholder"); 973 974fn draw_rename_editor( 975 ctx: &mut FrameCtx<'_>, 976 row_id: WidgetId, 977 label: &LabelText, 978 label_rect: LayoutRect, 979 state: &mut TreeViewState, 980) -> Vec<WidgetPaint> { 981 let placeholder = match label { 982 LabelText::Key(k) => *k, 983 LabelText::Owned(_) => RENAME_FALLBACK_PLACEHOLDER, 984 }; 985 let rename_id = row_id.child(WidgetKey::new("rename")); 986 let response = show_text_input( 987 ctx, 988 TextInput { 989 id: rename_id, 990 rect: label_rect, 991 placeholder, 992 state: &mut state.rename_buffer, 993 disabled: false, 994 validator: AlwaysValid, 995 }, 996 &mut state.clipboard, 997 ); 998 if !ctx.is_focused(rename_id) { 999 ctx.focus.request_focus(rename_id); 1000 } 1001 response.paint 1002} 1003 1004fn placement_from_pointer(pointer: LayoutPos, row: LayoutRect) -> DropPlacement { 1005 let local = pointer.y.value() - row.origin.y.value(); 1006 if local * 2.0 < row.size.height.value() { 1007 DropPlacement::Before 1008 } else { 1009 DropPlacement::After 1010 } 1011} 1012 1013fn drop_indicator_rect(row: LayoutRect, placement: DropPlacement) -> LayoutRect { 1014 let stripe = LayoutPx::new(2.0); 1015 match placement { 1016 DropPlacement::Before => { 1017 LayoutRect::new(row.origin, LayoutSize::new(row.size.width, stripe)) 1018 } 1019 DropPlacement::After => LayoutRect::new( 1020 LayoutPos::new( 1021 row.origin.x, 1022 LayoutPx::new(row.origin.y.value() + row.size.height.value() - stripe.value()), 1023 ), 1024 LayoutSize::new(row.size.width, stripe), 1025 ), 1026 } 1027} 1028 1029fn row_fill( 1030 ctx: &FrameCtx<'_>, 1031 interaction: &crate::hit_test::Interaction, 1032 selected: bool, 1033) -> Color { 1034 let neutral = ctx.theme().colors.neutral; 1035 if selected { 1036 ctx.theme().colors.accent.step(Step12::HOVER_BG) 1037 } else if interaction.hover() { 1038 neutral.step(Step12::HOVER_BG) 1039 } else { 1040 Color::TRANSPARENT 1041 } 1042} 1043 1044const DISCLOSURE_ICON_SIZE_PX: f32 = 14.0; 1045const DISCLOSURE_COLUMN_WIDTH_PX: f32 = 20.0; 1046 1047fn disclosure_icon_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 1048 let pad_y = (row.size.height.value() - DISCLOSURE_ICON_SIZE_PX) / 2.0; 1049 let pad_x = (DISCLOSURE_COLUMN_WIDTH_PX - DISCLOSURE_ICON_SIZE_PX) / 2.0; 1050 LayoutRect::new( 1051 LayoutPos::new( 1052 LayoutPx::new(row.origin.x.value() + indent.value() + pad_x), 1053 LayoutPx::new(row.origin.y.value() + pad_y), 1054 ), 1055 LayoutSize::new( 1056 LayoutPx::new(DISCLOSURE_ICON_SIZE_PX), 1057 LayoutPx::new(DISCLOSURE_ICON_SIZE_PX), 1058 ), 1059 ) 1060} 1061 1062fn disclosure_hit_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 1063 LayoutRect::new( 1064 LayoutPos::new( 1065 LayoutPx::new(row.origin.x.value() + indent.value()), 1066 row.origin.y, 1067 ), 1068 LayoutSize::new(LayoutPx::new(DISCLOSURE_COLUMN_WIDTH_PX), row.size.height), 1069 ) 1070} 1071 1072fn label_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 1073 LayoutRect::new( 1074 LayoutPos::new( 1075 LayoutPx::new(row.origin.x.value() + indent.value() + DISCLOSURE_COLUMN_WIDTH_PX), 1076 row.origin.y, 1077 ), 1078 LayoutSize::new( 1079 LayoutPx::saturating_nonneg( 1080 row.size.width.value() - indent.value() - DISCLOSURE_COLUMN_WIDTH_PX, 1081 ), 1082 row.size.height, 1083 ), 1084 ) 1085} 1086 1087fn update_selection( 1088 state: &mut TreeViewState, 1089 id: WidgetId, 1090 modifiers: ModifierMask, 1091 mode: TreeSelectionMode, 1092) { 1093 match mode { 1094 TreeSelectionMode::Single => { 1095 state.selection = BTreeSet::from([id]); 1096 } 1097 TreeSelectionMode::Multi => { 1098 if modifiers.contains(ModifierMask::CTRL) { 1099 if !state.selection.insert(id) { 1100 state.selection.remove(&id); 1101 } 1102 } else { 1103 state.selection = BTreeSet::from([id]); 1104 } 1105 } 1106 } 1107} 1108 1109fn toggle_expanded(state: &mut TreeViewState, id: WidgetId) { 1110 if !state.expanded.insert(id) { 1111 state.expanded.remove(&id); 1112 } 1113} 1114 1115fn handle_keyboard( 1116 ctx: &mut FrameCtx<'_>, 1117 visible: &[VisibleRow], 1118 state: &mut TreeViewState, 1119 renamable: &[WidgetId], 1120 activated: &mut Option<WidgetId>, 1121) { 1122 let in_tree_focus = ctx 1123 .focus 1124 .focused() 1125 .is_some_and(|f| visible.iter().any(|r| r.id == f)); 1126 if !in_tree_focus { 1127 return; 1128 } 1129 let focused = ctx.focus.focused(); 1130 let event = take_key( 1131 ctx.input, 1132 &[ 1133 TakeKey::named(NamedKey::ArrowUp), 1134 TakeKey::named(NamedKey::ArrowDown), 1135 TakeKey::named(NamedKey::ArrowLeft), 1136 TakeKey::named(NamedKey::ArrowRight), 1137 TakeKey::named(NamedKey::Home), 1138 TakeKey::named(NamedKey::End), 1139 TakeKey::named(NamedKey::Enter), 1140 TakeKey::named(NamedKey::Space), 1141 TakeKey::named(NamedKey::F2), 1142 ], 1143 ); 1144 let Some(event) = event else { return }; 1145 let current = focused.and_then(|f| visible.iter().position(|r| r.id == f)); 1146 match event.code { 1147 KeyCode::Named(NamedKey::ArrowDown) => { 1148 if let Some(idx) = current 1149 && idx + 1 < visible.len() 1150 { 1151 ctx.focus.request_focus(visible[idx + 1].id); 1152 state.focused = Some(visible[idx + 1].id); 1153 } 1154 } 1155 KeyCode::Named(NamedKey::ArrowUp) => { 1156 if let Some(idx) = current 1157 && idx > 0 1158 { 1159 ctx.focus.request_focus(visible[idx - 1].id); 1160 state.focused = Some(visible[idx - 1].id); 1161 } 1162 } 1163 KeyCode::Named(NamedKey::ArrowRight) => { 1164 if let Some(idx) = current 1165 && visible[idx].has_children 1166 && !state.expanded.contains(&visible[idx].id) 1167 { 1168 state.expanded.insert(visible[idx].id); 1169 } 1170 } 1171 KeyCode::Named(NamedKey::ArrowLeft) => { 1172 if let Some(idx) = current 1173 && state.expanded.contains(&visible[idx].id) 1174 { 1175 state.expanded.remove(&visible[idx].id); 1176 } 1177 } 1178 KeyCode::Named(NamedKey::Home) => { 1179 if let Some(first) = visible.first() { 1180 ctx.focus.request_focus(first.id); 1181 state.focused = Some(first.id); 1182 } 1183 } 1184 KeyCode::Named(NamedKey::End) => { 1185 if let Some(last) = visible.last() { 1186 ctx.focus.request_focus(last.id); 1187 state.focused = Some(last.id); 1188 } 1189 } 1190 KeyCode::Named(NamedKey::Enter | NamedKey::Space) => { 1191 if let Some(idx) = current { 1192 let id = visible[idx].id; 1193 state.selection = BTreeSet::from([id]); 1194 if activated.is_none() { 1195 *activated = Some(id); 1196 } 1197 } 1198 } 1199 KeyCode::Named(NamedKey::F2) => { 1200 if let Some(idx) = current { 1201 let row = &visible[idx]; 1202 if !row.disabled && renamable.contains(&row.id) { 1203 let text = match &row.label { 1204 LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 1205 LabelText::Owned(s) => s.clone(), 1206 }; 1207 state.renaming = Some(row.id); 1208 state.rename_buffer = TextInputState::from_text(&text); 1209 } 1210 } 1211 } 1212 _ => {} 1213 } 1214} 1215 1216#[cfg(test)] 1217mod tests { 1218 use std::collections::BTreeSet; 1219 use std::sync::Arc; 1220 1221 use super::{ 1222 PendingRename, TreeNode, TreeSelectionMode, TreeView, TreeViewState, show_tree_view, 1223 }; 1224 use crate::focus::FocusManager; 1225 use crate::frame::FrameCtx; 1226 use crate::hit_test::{HitFrame, HitState, resolve}; 1227 use crate::hotkey::HotkeyTable; 1228 use crate::input::{ 1229 DoubleClickWindow, FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, 1230 PointerButton, PointerButtonMask, PointerSample, 1231 }; 1232 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 1233 use crate::strings::{StringKey, StringTable}; 1234 use crate::theme::Theme; 1235 use crate::widget_id::{WidgetId, WidgetKey}; 1236 1237 fn root_id(name: &'static str) -> WidgetId { 1238 WidgetId::ROOT.child(WidgetKey::new(name)) 1239 } 1240 1241 fn sample_tree() -> Vec<TreeNode> { 1242 vec![ 1243 TreeNode::parent( 1244 root_id("origin"), 1245 StringKey::new("tree.origin"), 1246 vec![ 1247 TreeNode::leaf(root_id("plane_xy"), StringKey::new("tree.xy")), 1248 TreeNode::leaf(root_id("plane_yz"), StringKey::new("tree.yz")), 1249 ], 1250 ), 1251 TreeNode::leaf(root_id("sketch"), StringKey::new("tree.sketch")), 1252 ] 1253 } 1254 1255 fn render( 1256 roots: &[TreeNode], 1257 state: &mut TreeViewState, 1258 focus: &mut FocusManager, 1259 snap: &mut InputSnapshot, 1260 prev: &HitState, 1261 ) -> (super::TreeViewResponse, HitState) { 1262 render_with(roots, state, focus, snap, prev, &[]) 1263 } 1264 1265 fn render_with( 1266 roots: &[TreeNode], 1267 state: &mut TreeViewState, 1268 focus: &mut FocusManager, 1269 snap: &mut InputSnapshot, 1270 prev: &HitState, 1271 renamable: &[WidgetId], 1272 ) -> (super::TreeViewResponse, HitState) { 1273 let theme = Arc::new(Theme::light()); 1274 let table = HotkeyTable::new(); 1275 let mut hits = HitFrame::new(); 1276 let rect = LayoutRect::new( 1277 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1278 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 1279 ); 1280 let response = { 1281 let mut shaper = bone_text::Shaper::new(); 1282 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1283 let mut ctx = FrameCtx::new( 1284 theme, 1285 snap, 1286 focus, 1287 &table, 1288 StringTable::empty(), 1289 &mut hits, 1290 prev, 1291 &mut a11y, 1292 &mut shaper, 1293 ); 1294 show_tree_view( 1295 &mut ctx, 1296 TreeView::new( 1297 WidgetId::ROOT.child(WidgetKey::new("tree")), 1298 rect, 1299 StringKey::new("test.tree"), 1300 roots, 1301 state, 1302 ) 1303 .renamable(renamable), 1304 ) 1305 }; 1306 let next = resolve(prev, &hits, snap, focus.focused()); 1307 (response, next) 1308 } 1309 1310 fn press(pos: LayoutPos) -> InputSnapshot { 1311 press_at(pos, FrameInstant::ZERO) 1312 } 1313 1314 fn release(pos: LayoutPos) -> InputSnapshot { 1315 release_at(pos, FrameInstant::ZERO) 1316 } 1317 1318 fn idle(pos: LayoutPos) -> InputSnapshot { 1319 idle_at(pos, FrameInstant::ZERO) 1320 } 1321 1322 fn press_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 1323 let mut s = InputSnapshot::idle(at); 1324 s.pointer = Some(PointerSample::new(pos)); 1325 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 1326 s 1327 } 1328 1329 fn release_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 1330 let mut s = InputSnapshot::idle(at); 1331 s.pointer = Some(PointerSample::new(pos)); 1332 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 1333 s 1334 } 1335 1336 fn idle_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 1337 let mut s = InputSnapshot::idle(at); 1338 s.pointer = Some(PointerSample::new(pos)); 1339 s 1340 } 1341 1342 fn secondary_press(pos: LayoutPos) -> InputSnapshot { 1343 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1344 s.pointer = Some(PointerSample::new(pos)); 1345 s.buttons_pressed = PointerButtonMask::just(PointerButton::Secondary); 1346 s 1347 } 1348 1349 fn secondary_release(pos: LayoutPos) -> InputSnapshot { 1350 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1351 s.pointer = Some(PointerSample::new(pos)); 1352 s.buttons_released = PointerButtonMask::just(PointerButton::Secondary); 1353 s 1354 } 1355 1356 #[test] 1357 fn secondary_click_requests_context_menu_and_selects_row() { 1358 let roots = sample_tree(); 1359 let mut state = TreeViewState::default(); 1360 let mut focus = FocusManager::new(); 1361 let mut prev = HitState::new(); 1362 let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); 1363 let mut last: Option<super::TreeViewResponse> = None; 1364 [secondary_press(pos), secondary_release(pos), idle(pos)] 1365 .into_iter() 1366 .for_each(|mut snap| { 1367 let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1368 last = Some(response); 1369 prev = next; 1370 }); 1371 let Some(request) = last.and_then(|r| r.context_menu) else { 1372 panic!("secondary click requests a context menu"); 1373 }; 1374 assert_eq!(request.target, root_id("sketch")); 1375 assert!(state.selection.contains(&root_id("sketch"))); 1376 } 1377 1378 #[test] 1379 fn secondary_click_does_not_activate_row() { 1380 let roots = sample_tree(); 1381 let mut state = TreeViewState::default(); 1382 let mut focus = FocusManager::new(); 1383 let mut prev = HitState::new(); 1384 let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); 1385 [secondary_press(pos), secondary_release(pos), idle(pos)] 1386 .into_iter() 1387 .for_each(|mut snap| { 1388 let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1389 assert!(response.activated.is_none()); 1390 prev = next; 1391 }); 1392 } 1393 1394 #[test] 1395 fn click_disclosure_toggles_expansion() { 1396 let roots = sample_tree(); 1397 let mut state = TreeViewState::default(); 1398 let mut focus = FocusManager::new(); 1399 let mut prev = HitState::new(); 1400 let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0)); 1401 [press(pos), release(pos), idle(pos)] 1402 .into_iter() 1403 .for_each(|mut snap| { 1404 let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1405 prev = next; 1406 }); 1407 assert!(state.expanded.contains(&roots[0].id)); 1408 } 1409 1410 #[test] 1411 fn click_indent_column_outside_old_icon_still_toggles() { 1412 let roots = sample_tree(); 1413 let mut state = TreeViewState::default(); 1414 let mut focus = FocusManager::new(); 1415 let mut prev = HitState::new(); 1416 let pos = LayoutPos::new(LayoutPx::new(18.0), LayoutPx::new(11.0)); 1417 [press(pos), release(pos), idle(pos)] 1418 .into_iter() 1419 .for_each(|mut snap| { 1420 let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1421 prev = next; 1422 }); 1423 assert!( 1424 state.expanded.contains(&roots[0].id), 1425 "indent column click toggles even outside the 14px icon", 1426 ); 1427 } 1428 1429 #[test] 1430 fn click_disclosure_toggles_back_after_collapse() { 1431 let roots = sample_tree(); 1432 let mut state = TreeViewState::default(); 1433 state.expanded.insert(roots[0].id); 1434 let mut focus = FocusManager::new(); 1435 let mut prev = HitState::new(); 1436 let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0)); 1437 [press(pos), release(pos), idle(pos)] 1438 .into_iter() 1439 .for_each(|mut snap| { 1440 let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1441 prev = next; 1442 }); 1443 assert!(!state.expanded.contains(&roots[0].id), "first click closes"); 1444 [press(pos), release(pos), idle(pos)] 1445 .into_iter() 1446 .for_each(|mut snap| { 1447 let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1448 prev = next; 1449 }); 1450 assert!( 1451 state.expanded.contains(&roots[0].id), 1452 "second click reopens", 1453 ); 1454 } 1455 1456 #[test] 1457 fn click_label_selects_node_and_marks_active() { 1458 let roots = sample_tree(); 1459 let mut state = TreeViewState::default(); 1460 let mut focus = FocusManager::new(); 1461 let mut prev = HitState::new(); 1462 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1463 let mut last: Option<super::TreeViewResponse> = None; 1464 [press(click_pos), release(click_pos), idle(click_pos)] 1465 .into_iter() 1466 .for_each(|mut snap| { 1467 let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1468 last = Some(response); 1469 prev = next; 1470 }); 1471 let Some(response) = last else { 1472 panic!("response missing") 1473 }; 1474 assert_eq!(response.activated, Some(roots[0].id)); 1475 assert!(state.selection.contains(&roots[0].id)); 1476 } 1477 1478 #[test] 1479 fn arrow_down_moves_focus_to_next_visible() { 1480 let roots = sample_tree(); 1481 let mut state = TreeViewState::default(); 1482 state.expanded.insert(roots[0].id); 1483 let mut focus = FocusManager::new(); 1484 focus.register_focusable(roots[0].id); 1485 focus.request_focus(roots[0].id); 1486 focus.end_frame(); 1487 let prev = HitState::new(); 1488 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1489 snap.keys_pressed.push(KeyEvent::new( 1490 KeyCode::Named(NamedKey::ArrowDown), 1491 ModifierMask::NONE, 1492 )); 1493 let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1494 assert_eq!(state.focused, Some(roots[0].children[0].id)); 1495 } 1496 1497 #[test] 1498 fn arrow_right_expands_collapsed_parent() { 1499 let roots = sample_tree(); 1500 let mut state = TreeViewState::default(); 1501 let mut focus = FocusManager::new(); 1502 focus.register_focusable(roots[0].id); 1503 focus.request_focus(roots[0].id); 1504 focus.end_frame(); 1505 let prev = HitState::new(); 1506 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1507 snap.keys_pressed.push(KeyEvent::new( 1508 KeyCode::Named(NamedKey::ArrowRight), 1509 ModifierMask::NONE, 1510 )); 1511 let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1512 assert!(state.expanded.contains(&roots[0].id)); 1513 } 1514 1515 #[test] 1516 fn ctrl_click_in_multi_mode_toggles_selection_membership() { 1517 let roots = sample_tree(); 1518 let mut state = TreeViewState { 1519 selection: BTreeSet::from([roots[1].id]), 1520 ..TreeViewState::default() 1521 }; 1522 let mut focus = FocusManager::new(); 1523 let mut prev = HitState::new(); 1524 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1525 let theme = Arc::new(Theme::light()); 1526 let table = HotkeyTable::new(); 1527 let rect = LayoutRect::new( 1528 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1529 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 1530 ); 1531 [press(click_pos), release(click_pos), idle(click_pos)] 1532 .into_iter() 1533 .for_each(|mut snap| { 1534 snap.modifiers = ModifierMask::CTRL; 1535 let mut hits = HitFrame::new(); 1536 { 1537 let mut shaper = bone_text::Shaper::new(); 1538 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1539 let mut ctx = FrameCtx::new( 1540 theme.clone(), 1541 &mut snap, 1542 &mut focus, 1543 &table, 1544 StringTable::empty(), 1545 &mut hits, 1546 &prev, 1547 &mut a11y, 1548 &mut shaper, 1549 ); 1550 let _ = show_tree_view( 1551 &mut ctx, 1552 TreeView::new( 1553 WidgetId::ROOT.child(WidgetKey::new("tree")), 1554 rect, 1555 StringKey::new("test.tree"), 1556 &roots, 1557 &mut state, 1558 ) 1559 .mode(TreeSelectionMode::Multi), 1560 ); 1561 } 1562 prev = resolve(&prev, &hits, &snap, focus.focused()); 1563 }); 1564 assert!(state.selection.contains(&roots[0].id)); 1565 assert!(state.selection.contains(&roots[1].id)); 1566 } 1567 1568 #[test] 1569 fn tree_registers_single_tab_stop_so_tab_does_not_walk_every_row() { 1570 let roots = sample_tree(); 1571 let mut state = TreeViewState::default(); 1572 state.expanded.insert(roots[0].id); 1573 let mut focus = FocusManager::new(); 1574 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1575 let prev = HitState::new(); 1576 let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1577 let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect(); 1578 assert_eq!(stops, vec![roots[0].id]); 1579 } 1580 1581 #[test] 1582 fn click_row_focuses_it_so_arrow_keys_engage() { 1583 let roots = sample_tree(); 1584 let mut state = TreeViewState::default(); 1585 let mut focus = FocusManager::new(); 1586 let mut prev = HitState::new(); 1587 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1588 [press(click_pos), release(click_pos), idle(click_pos)] 1589 .into_iter() 1590 .for_each(|mut snap| { 1591 let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1592 prev = next; 1593 }); 1594 assert_eq!(focus.focused(), Some(roots[0].id)); 1595 } 1596 1597 #[test] 1598 fn enter_with_renaming_clears_state_and_emits_committed() { 1599 use super::RenameCommit; 1600 use crate::widgets::text_input::TextInputState; 1601 let roots = sample_tree(); 1602 let mut state = TreeViewState { 1603 renaming: Some(roots[0].id), 1604 rename_buffer: TextInputState::from_text("renamed"), 1605 ..TreeViewState::default() 1606 }; 1607 let mut focus = FocusManager::new(); 1608 focus.register_focusable(roots[0].id); 1609 focus.request_focus(roots[0].id); 1610 focus.end_frame(); 1611 let prev = HitState::new(); 1612 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1613 snap.keys_pressed.push(KeyEvent::new( 1614 KeyCode::Named(NamedKey::Enter), 1615 ModifierMask::NONE, 1616 )); 1617 let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1618 assert_eq!( 1619 response.rename_committed, 1620 Some(RenameCommit { 1621 id: roots[0].id, 1622 text: "renamed".into(), 1623 }), 1624 ); 1625 assert!(state.renaming.is_none()); 1626 } 1627 1628 #[test] 1629 fn keyboard_enter_on_focused_row_emits_activated() { 1630 let roots = sample_tree(); 1631 let mut state = TreeViewState::default(); 1632 let mut focus = FocusManager::new(); 1633 focus.register_focusable(roots[0].id); 1634 focus.request_focus(roots[0].id); 1635 focus.end_frame(); 1636 let prev = HitState::new(); 1637 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1638 snap.keys_pressed.push(KeyEvent::new( 1639 KeyCode::Named(NamedKey::Enter), 1640 ModifierMask::NONE, 1641 )); 1642 let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1643 assert_eq!(response.activated, Some(roots[0].id)); 1644 } 1645 1646 #[test] 1647 fn f2_on_renamable_focused_row_starts_rename_prefilled_with_label() { 1648 let sketch_id = root_id("sketch"); 1649 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1650 let mut state = TreeViewState::default(); 1651 let mut focus = FocusManager::new(); 1652 focus.register_focusable(sketch_id); 1653 focus.request_focus(sketch_id); 1654 focus.end_frame(); 1655 let prev = HitState::new(); 1656 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1657 snap.keys_pressed.push(KeyEvent::new( 1658 KeyCode::Named(NamedKey::F2), 1659 ModifierMask::NONE, 1660 )); 1661 let (_, _) = render_with( 1662 &roots, 1663 &mut state, 1664 &mut focus, 1665 &mut snap, 1666 &prev, 1667 &[sketch_id], 1668 ); 1669 assert_eq!(state.renaming, Some(sketch_id)); 1670 assert_eq!(state.rename_buffer.text, "Profile"); 1671 } 1672 1673 #[test] 1674 fn slow_click_on_already_selected_renamable_row_starts_rename() { 1675 let sketch_id = root_id("sketch"); 1676 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1677 let mut state = TreeViewState { 1678 selection: BTreeSet::from([sketch_id]), 1679 ..TreeViewState::default() 1680 }; 1681 let mut focus = FocusManager::new(); 1682 let mut prev = HitState::new(); 1683 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1684 let window = DoubleClickWindow::DEFAULT.duration(); 1685 let after_click = FrameInstant::from_duration(core::time::Duration::from_millis(10)); 1686 let after_window = FrameInstant::from_duration( 1687 after_click.duration() + window + core::time::Duration::from_millis(5), 1688 ); 1689 let frames = [ 1690 press_at(click_pos, FrameInstant::ZERO), 1691 release_at(click_pos, FrameInstant::ZERO), 1692 idle_at(click_pos, after_click), 1693 idle_at(click_pos, after_window), 1694 ]; 1695 frames.into_iter().for_each(|mut snap| { 1696 let (_, next) = render_with( 1697 &roots, 1698 &mut state, 1699 &mut focus, 1700 &mut snap, 1701 &prev, 1702 &[sketch_id], 1703 ); 1704 prev = next; 1705 }); 1706 assert_eq!(state.renaming, Some(sketch_id)); 1707 assert_eq!(state.rename_buffer.text, "Profile"); 1708 } 1709 1710 #[test] 1711 fn primary_press_outside_pending_row_cancels_rename() { 1712 let sketch_id = root_id("sketch"); 1713 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1714 let mut state = TreeViewState { 1715 selection: BTreeSet::from([sketch_id]), 1716 ..TreeViewState::default() 1717 }; 1718 let mut focus = FocusManager::new(); 1719 let mut prev = HitState::new(); 1720 let row_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1721 let elsewhere = LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0)); 1722 let armed = FrameInstant::from_duration(core::time::Duration::from_millis(10)); 1723 let pressed_away = FrameInstant::from_duration(core::time::Duration::from_millis(20)); 1724 let frames = [ 1725 press_at(row_pos, FrameInstant::ZERO), 1726 release_at(row_pos, FrameInstant::ZERO), 1727 idle_at(row_pos, armed), 1728 press_at(elsewhere, pressed_away), 1729 ]; 1730 frames.into_iter().for_each(|mut snap| { 1731 let (_, next) = render_with( 1732 &roots, 1733 &mut state, 1734 &mut focus, 1735 &mut snap, 1736 &prev, 1737 &[sketch_id], 1738 ); 1739 prev = next; 1740 }); 1741 assert_eq!( 1742 state.pending_rename, None, 1743 "pressing outside the pending row cancels the slow-click rename", 1744 ); 1745 assert_eq!(state.renaming, None, "no rename editor opens"); 1746 } 1747 1748 #[test] 1749 fn extra_slow_click_on_already_pending_row_does_not_reset_pending_at() { 1750 let sketch_id = root_id("sketch"); 1751 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1752 let original_at = FrameInstant::from_duration(core::time::Duration::from_millis(5)); 1753 let mut state = TreeViewState { 1754 selection: BTreeSet::from([sketch_id]), 1755 pending_rename: Some(PendingRename { 1756 id: sketch_id, 1757 at: original_at, 1758 }), 1759 ..TreeViewState::default() 1760 }; 1761 let mut focus = FocusManager::new(); 1762 let mut prev = HitState::new(); 1763 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1764 let new_click = FrameInstant::from_duration(core::time::Duration::from_millis(120)); 1765 let new_visible = FrameInstant::from_duration( 1766 new_click.duration() + core::time::Duration::from_millis(10), 1767 ); 1768 let frames = [ 1769 press_at(click_pos, new_click), 1770 release_at(click_pos, new_click), 1771 idle_at(click_pos, new_visible), 1772 ]; 1773 frames.into_iter().for_each(|mut snap| { 1774 let (_, next) = render_with( 1775 &roots, 1776 &mut state, 1777 &mut focus, 1778 &mut snap, 1779 &prev, 1780 &[sketch_id], 1781 ); 1782 prev = next; 1783 }); 1784 let Some(pending) = state.pending_rename else { 1785 panic!("pending rename must persist across slow extra clicks"); 1786 }; 1787 assert_eq!( 1788 pending.at, original_at, 1789 "subsequent slow click on already-pending row must keep the original at", 1790 ); 1791 } 1792 1793 #[test] 1794 fn double_click_on_already_selected_renamable_row_does_not_start_rename() { 1795 let sketch_id = root_id("sketch"); 1796 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1797 let mut state = TreeViewState { 1798 selection: BTreeSet::from([sketch_id]), 1799 ..TreeViewState::default() 1800 }; 1801 let mut focus = FocusManager::new(); 1802 let mut prev = HitState::new(); 1803 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1804 let window = DoubleClickWindow::DEFAULT.duration(); 1805 let fast = FrameInstant::from_duration(core::time::Duration::from_millis(60)); 1806 let after_window = FrameInstant::from_duration( 1807 fast.duration() + window + core::time::Duration::from_millis(5), 1808 ); 1809 let frames = [ 1810 press_at(click_pos, FrameInstant::ZERO), 1811 release_at(click_pos, FrameInstant::ZERO), 1812 press_at(click_pos, fast), 1813 release_at(click_pos, fast), 1814 idle_at(click_pos, after_window), 1815 ]; 1816 frames.into_iter().for_each(|mut snap| { 1817 let (_, next) = render_with( 1818 &roots, 1819 &mut state, 1820 &mut focus, 1821 &mut snap, 1822 &prev, 1823 &[sketch_id], 1824 ); 1825 prev = next; 1826 }); 1827 assert_eq!( 1828 state.renaming, None, 1829 "fast double-click on already-selected row must not start rename", 1830 ); 1831 assert_eq!( 1832 state.pending_rename, None, 1833 "double-click must clear any deferred rename", 1834 ); 1835 } 1836 1837 #[test] 1838 fn double_click_on_renamable_row_does_not_start_rename() { 1839 let sketch_id = root_id("sketch"); 1840 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1841 let mut state = TreeViewState::default(); 1842 let mut focus = FocusManager::new(); 1843 let mut prev = HitState::new(); 1844 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1845 let frames = [ 1846 press(click_pos), 1847 release(click_pos), 1848 idle(click_pos), 1849 press(click_pos), 1850 release(click_pos), 1851 idle(click_pos), 1852 ]; 1853 let mut last: Option<super::TreeViewResponse> = None; 1854 frames.into_iter().for_each(|mut snap| { 1855 let (response, next) = render_with( 1856 &roots, 1857 &mut state, 1858 &mut focus, 1859 &mut snap, 1860 &prev, 1861 &[sketch_id], 1862 ); 1863 last = Some(response); 1864 prev = next; 1865 }); 1866 assert!( 1867 state.renaming.is_none(), 1868 "fast double-click must enter via double_activated, not rename", 1869 ); 1870 let Some(response) = last else { 1871 panic!("response captured"); 1872 }; 1873 assert_eq!(response.double_activated, Some(sketch_id)); 1874 } 1875 1876 #[test] 1877 fn f2_on_non_renamable_row_is_ignored() { 1878 let roots = sample_tree(); 1879 let mut state = TreeViewState::default(); 1880 let mut focus = FocusManager::new(); 1881 focus.register_focusable(roots[1].id); 1882 focus.request_focus(roots[1].id); 1883 focus.end_frame(); 1884 let prev = HitState::new(); 1885 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1886 snap.keys_pressed.push(KeyEvent::new( 1887 KeyCode::Named(NamedKey::F2), 1888 ModifierMask::NONE, 1889 )); 1890 let (_, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1891 assert!(state.renaming.is_none()); 1892 } 1893 1894 fn tall_roots(n: u64) -> Vec<TreeNode> { 1895 (0..n) 1896 .map(|i| { 1897 TreeNode::leaf_owned( 1898 WidgetId::ROOT.child_indexed(WidgetKey::new("row"), i), 1899 format!("row{i}"), 1900 ) 1901 }) 1902 .collect() 1903 } 1904 1905 fn render_scroll( 1906 roots: &[TreeNode], 1907 state: &mut TreeViewState, 1908 height: f32, 1909 ) -> (Vec<super::WidgetPaint>, bool) { 1910 let theme = Arc::new(Theme::light()); 1911 let table = HotkeyTable::new(); 1912 let mut focus = FocusManager::new(); 1913 let mut hits = HitFrame::new(); 1914 let prev = HitState::new(); 1915 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1916 let rect = LayoutRect::new( 1917 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1918 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(height)), 1919 ); 1920 let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 1921 let scrollbar_id = tree_id.child(WidgetKey::new("scrollbar")); 1922 let mut shaper = bone_text::Shaper::new(); 1923 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1924 let paint = { 1925 let mut ctx = FrameCtx::new( 1926 theme, 1927 &mut snap, 1928 &mut focus, 1929 &table, 1930 StringTable::empty(), 1931 &mut hits, 1932 &prev, 1933 &mut a11y, 1934 &mut shaper, 1935 ); 1936 show_tree_view( 1937 &mut ctx, 1938 TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state), 1939 ) 1940 .paint 1941 }; 1942 (paint, a11y.contains(scrollbar_id)) 1943 } 1944 1945 fn paints_owned_label(paint: &[super::WidgetPaint], text: &str) -> bool { 1946 use super::{HorizontalAlign, LabelText, WidgetPaint}; 1947 paint.iter().any(|p| { 1948 matches!( 1949 p, 1950 WidgetPaint::AlignedLabel { text: LabelText::Owned(t), align: HorizontalAlign::Start, .. } 1951 if t == text 1952 ) 1953 }) 1954 } 1955 1956 #[test] 1957 fn tall_tree_culls_offscreen_rows_and_shows_a_scrollbar() { 1958 let roots = tall_roots(40); 1959 let mut state = TreeViewState::default(); 1960 let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0); 1961 assert!(has_bar, "an overflowing tree shows a scrollbar"); 1962 assert!(paints_owned_label(&paint, "row0"), "the first row paints"); 1963 assert!( 1964 !paints_owned_label(&paint, "row39"), 1965 "the last row is culled offscreen", 1966 ); 1967 assert_eq!( 1968 state.scroll_offset, 1969 LayoutPx::ZERO, 1970 "offset snaps to the top" 1971 ); 1972 } 1973 1974 #[test] 1975 fn scrolling_offset_reveals_later_rows() { 1976 let roots = tall_roots(40); 1977 let mut state = TreeViewState { 1978 scroll_offset: LayoutPx::new(400.0), 1979 ..TreeViewState::default() 1980 }; 1981 let (paint, _) = render_scroll(&roots, &mut state, 400.0); 1982 assert!( 1983 paints_owned_label(&paint, "row20"), 1984 "the scrolled-to row paints", 1985 ); 1986 assert!( 1987 !paints_owned_label(&paint, "row0"), 1988 "the top row is culled after scrolling", 1989 ); 1990 } 1991 1992 #[test] 1993 fn tree_that_fits_shows_no_scrollbar() { 1994 let roots = tall_roots(3); 1995 let mut state = TreeViewState::default(); 1996 let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0); 1997 assert!(!has_bar, "a tree that fits its viewport shows no scrollbar"); 1998 assert!(paints_owned_label(&paint, "row2")); 1999 } 2000 2001 fn render_scroll_wheel( 2002 roots: &[TreeNode], 2003 state: &mut TreeViewState, 2004 scroll_y: f32, 2005 ) -> Vec<super::WidgetPaint> { 2006 let theme = Arc::new(Theme::light()); 2007 let table = HotkeyTable::new(); 2008 let mut focus = FocusManager::new(); 2009 let mut hits = HitFrame::new(); 2010 let prev = HitState::new(); 2011 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 2012 snap.pointer = Some(PointerSample::new(LayoutPos::new( 2013 LayoutPx::new(100.0), 2014 LayoutPx::new(100.0), 2015 ))); 2016 snap.scroll_y = scroll_y; 2017 let rect = LayoutRect::new( 2018 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 2019 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 2020 ); 2021 let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 2022 let mut shaper = bone_text::Shaper::new(); 2023 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 2024 let mut ctx = FrameCtx::new( 2025 theme, 2026 &mut snap, 2027 &mut focus, 2028 &table, 2029 StringTable::empty(), 2030 &mut hits, 2031 &prev, 2032 &mut a11y, 2033 &mut shaper, 2034 ); 2035 show_tree_view( 2036 &mut ctx, 2037 TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state), 2038 ) 2039 .paint 2040 } 2041 2042 #[test] 2043 fn wheel_over_the_tree_reveals_later_rows() { 2044 let roots = tall_roots(40); 2045 let mut state = TreeViewState::default(); 2046 let paint = render_scroll_wheel(&roots, &mut state, 100.0); 2047 assert!( 2048 state.scroll_offset.value() > 0.0, 2049 "a wheel notch over the tree advances the offset", 2050 ); 2051 assert!( 2052 paints_owned_label(&paint, "row5"), 2053 "a later row is revealed" 2054 ); 2055 assert!( 2056 !paints_owned_label(&paint, "row0"), 2057 "the top row scrolls off", 2058 ); 2059 } 2060 2061 #[test] 2062 fn wheel_off_the_tree_does_not_scroll() { 2063 let roots = tall_roots(40); 2064 let mut state = TreeViewState::default(); 2065 let theme = Arc::new(Theme::light()); 2066 let table = HotkeyTable::new(); 2067 let mut focus = FocusManager::new(); 2068 let mut hits = HitFrame::new(); 2069 let prev = HitState::new(); 2070 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 2071 snap.pointer = Some(PointerSample::new(LayoutPos::new( 2072 LayoutPx::new(900.0), 2073 LayoutPx::new(900.0), 2074 ))); 2075 snap.scroll_y = 100.0; 2076 let rect = LayoutRect::new( 2077 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 2078 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 2079 ); 2080 let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 2081 let mut shaper = bone_text::Shaper::new(); 2082 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 2083 { 2084 let mut ctx = FrameCtx::new( 2085 theme, 2086 &mut snap, 2087 &mut focus, 2088 &table, 2089 StringTable::empty(), 2090 &mut hits, 2091 &prev, 2092 &mut a11y, 2093 &mut shaper, 2094 ); 2095 let _ = show_tree_view( 2096 &mut ctx, 2097 TreeView::new( 2098 tree_id, 2099 rect, 2100 StringKey::new("test.tree"), 2101 &roots, 2102 &mut state, 2103 ), 2104 ); 2105 } 2106 assert_eq!( 2107 state.scroll_offset, 2108 LayoutPx::ZERO, 2109 "a wheel notch away from the tree leaves it unscrolled", 2110 ); 2111 } 2112}