Another project
0

Configure Feed

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

feat(ui): tree view widget

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

author
Lewis
date (Apr 30, 2026, 10:04 AM +0300) commit 4024ab59 parent ccb4c748 change-id xtlwtowy
+935
+935
crates/bone-ui/src/widgets/tree_view.rs
··· 1 + use std::collections::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::{Color, Step12}; 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::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 14 + use super::visuals::push_focus_ring; 15 + 16 + #[derive(Clone, Debug, PartialEq)] 17 + pub struct TreeNode { 18 + pub id: WidgetId, 19 + pub label: StringKey, 20 + pub children: Vec<TreeNode>, 21 + } 22 + 23 + impl TreeNode { 24 + #[must_use] 25 + pub const fn leaf(id: WidgetId, label: StringKey) -> Self { 26 + Self { 27 + id, 28 + label, 29 + children: Vec::new(), 30 + } 31 + } 32 + 33 + #[must_use] 34 + pub const fn parent(id: WidgetId, label: StringKey, children: Vec<TreeNode>) -> Self { 35 + Self { 36 + id, 37 + label, 38 + children, 39 + } 40 + } 41 + 42 + #[must_use] 43 + pub fn has_children(&self) -> bool { 44 + !self.children.is_empty() 45 + } 46 + } 47 + 48 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 49 + pub enum TreeSelectionMode { 50 + Single, 51 + Multi, 52 + } 53 + 54 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 55 + pub enum DropPlacement { 56 + Before, 57 + After, 58 + Into, 59 + } 60 + 61 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 62 + pub struct DropTarget { 63 + pub anchor: WidgetId, 64 + pub placement: DropPlacement, 65 + } 66 + 67 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 68 + pub struct TreeViewState { 69 + pub expanded: BTreeSet<WidgetId>, 70 + pub selection: BTreeSet<WidgetId>, 71 + pub focused: Option<WidgetId>, 72 + pub renaming: Option<WidgetId>, 73 + pub rename_buffer: TextInputState, 74 + pub clipboard: MemoryClipboard, 75 + pub drag_source: Option<WidgetId>, 76 + pub drop_target: Option<DropTarget>, 77 + } 78 + 79 + #[derive(Debug, PartialEq)] 80 + pub struct TreeView<'a, 'state> { 81 + pub id: WidgetId, 82 + pub rect: LayoutRect, 83 + pub roots: &'a [TreeNode], 84 + pub state: &'state mut TreeViewState, 85 + pub mode: TreeSelectionMode, 86 + pub row_height: LayoutPx, 87 + pub indent_step: LayoutPx, 88 + } 89 + 90 + impl<'a, 'state> TreeView<'a, 'state> { 91 + #[must_use] 92 + pub const fn new( 93 + id: WidgetId, 94 + rect: LayoutRect, 95 + roots: &'a [TreeNode], 96 + state: &'state mut TreeViewState, 97 + ) -> Self { 98 + Self { 99 + id, 100 + rect, 101 + roots, 102 + state, 103 + mode: TreeSelectionMode::Single, 104 + row_height: LayoutPx::new(22.0), 105 + indent_step: LayoutPx::new(16.0), 106 + } 107 + } 108 + 109 + #[must_use] 110 + pub const fn mode(self, mode: TreeSelectionMode) -> Self { 111 + Self { mode, ..self } 112 + } 113 + } 114 + 115 + #[derive(Clone, Debug, PartialEq, Eq)] 116 + pub struct RenameCommit { 117 + pub id: WidgetId, 118 + pub text: String, 119 + } 120 + 121 + #[derive(Clone, Debug, PartialEq)] 122 + pub struct TreeViewResponse { 123 + pub activated: Option<WidgetId>, 124 + pub double_activated: Option<WidgetId>, 125 + pub rename_committed: Option<RenameCommit>, 126 + pub rename_cancelled: Option<WidgetId>, 127 + pub drop_committed: Option<(WidgetId, DropTarget)>, 128 + pub paint: Vec<WidgetPaint>, 129 + } 130 + 131 + #[must_use] 132 + pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeViewResponse { 133 + let TreeView { 134 + id: _, 135 + rect, 136 + roots, 137 + state, 138 + mode, 139 + row_height, 140 + indent_step, 141 + } = view; 142 + let mut paint = vec![WidgetPaint::Surface { 143 + rect, 144 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L0), 145 + border: None, 146 + radius: ctx.theme.radius.none, 147 + elevation: None, 148 + }]; 149 + let visible: Vec<VisibleRow> = flatten(roots, &state.expanded, 0); 150 + let entry_id = state 151 + .focused 152 + .filter(|f| visible.iter().any(|r| r.id == *f)) 153 + .or_else(|| visible.first().map(|r| r.id)); 154 + if let Some(id) = entry_id { 155 + ctx.focus.register_tab_stop(id); 156 + } 157 + let mut activated: Option<WidgetId> = None; 158 + let mut double_activated: Option<WidgetId> = None; 159 + let mut drop_committed: Option<(WidgetId, DropTarget)> = None; 160 + let row_paint = visible 161 + .iter() 162 + .enumerate() 163 + .map(|(idx, row)| { 164 + let row_rect = row_rect_at(rect, idx, row_height); 165 + draw_row( 166 + ctx, 167 + RowDrawArgs { 168 + row, 169 + row_rect, 170 + indent_step, 171 + state, 172 + mode, 173 + }, 174 + &mut activated, 175 + &mut double_activated, 176 + &mut drop_committed, 177 + ) 178 + }) 179 + .fold(Vec::new(), |mut acc, mut p| { 180 + acc.append(&mut p); 181 + acc 182 + }); 183 + paint.extend(row_paint); 184 + let rename_committed = if let Some(id) = state.renaming 185 + && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 186 + { 187 + let text = state.rename_buffer.text.clone(); 188 + state.renaming = None; 189 + state.rename_buffer = TextInputState::default(); 190 + Some(RenameCommit { id, text }) 191 + } else { 192 + None 193 + }; 194 + let rename_cancelled = if let Some(id) = state.renaming 195 + && take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some() 196 + { 197 + state.renaming = None; 198 + state.rename_buffer = TextInputState::default(); 199 + Some(id) 200 + } else { 201 + None 202 + }; 203 + if state.renaming.is_none() { 204 + handle_keyboard(ctx, &visible, state, &mut activated); 205 + } 206 + TreeViewResponse { 207 + activated, 208 + double_activated, 209 + rename_committed, 210 + rename_cancelled, 211 + drop_committed, 212 + paint, 213 + } 214 + } 215 + 216 + #[derive(Clone, Debug, PartialEq)] 217 + struct VisibleRow { 218 + id: WidgetId, 219 + label: StringKey, 220 + depth: usize, 221 + has_children: bool, 222 + } 223 + 224 + fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> { 225 + roots 226 + .iter() 227 + .flat_map(|node| { 228 + let row = VisibleRow { 229 + id: node.id, 230 + label: node.label, 231 + depth, 232 + has_children: node.has_children(), 233 + }; 234 + let children = if expanded.contains(&node.id) { 235 + flatten(&node.children, expanded, depth + 1) 236 + } else { 237 + Vec::new() 238 + }; 239 + std::iter::once(row).chain(children) 240 + }) 241 + .collect() 242 + } 243 + 244 + fn row_rect_at(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { 245 + #[allow( 246 + clippy::cast_precision_loss, 247 + reason = "tree row index fits f32 mantissa" 248 + )] 249 + let i = idx as f32; 250 + LayoutRect::new( 251 + LayoutPos::new( 252 + view.origin.x, 253 + LayoutPx::new(view.origin.y.value() + i * row_height.value()), 254 + ), 255 + LayoutSize::new(view.size.width, row_height), 256 + ) 257 + } 258 + 259 + struct RowDrawArgs<'a> { 260 + row: &'a VisibleRow, 261 + row_rect: LayoutRect, 262 + indent_step: LayoutPx, 263 + state: &'a mut TreeViewState, 264 + mode: TreeSelectionMode, 265 + } 266 + 267 + fn draw_row( 268 + ctx: &mut FrameCtx<'_>, 269 + args: RowDrawArgs<'_>, 270 + activated: &mut Option<WidgetId>, 271 + double_activated: &mut Option<WidgetId>, 272 + drop_committed: &mut Option<(WidgetId, DropTarget)>, 273 + ) -> Vec<WidgetPaint> { 274 + let RowDrawArgs { 275 + row, 276 + row_rect, 277 + indent_step, 278 + state, 279 + mode, 280 + } = args; 281 + #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] 282 + let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); 283 + let disclosure_rect = disclosure_rect_at(row_rect, indent); 284 + let label_rect = label_rect_at(row_rect, indent); 285 + let interaction = ctx.interact( 286 + InteractDeclaration::new(row.id, row_rect, Sense::DRAGGABLE) 287 + .focusable(false) 288 + .active(state.selection.contains(&row.id)), 289 + ); 290 + let live_focused = ctx.is_focused(row.id); 291 + apply_row_interaction( 292 + ctx, 293 + row, 294 + row_rect, 295 + &interaction, 296 + state, 297 + mode, 298 + RowOutcomes { 299 + activated, 300 + double_activated, 301 + drop_committed, 302 + }, 303 + ); 304 + let mut paint = vec![WidgetPaint::Surface { 305 + rect: row_rect, 306 + fill: row_fill(ctx, &interaction, state.selection.contains(&row.id)), 307 + border: None, 308 + radius: ctx.theme.radius.none, 309 + elevation: None, 310 + }]; 311 + if row.has_children { 312 + paint.extend(draw_disclosure(ctx, row, disclosure_rect, state)); 313 + } 314 + if state.renaming == Some(row.id) { 315 + paint.extend(draw_rename_editor(ctx, row.id, row.label, label_rect, state)); 316 + } else { 317 + paint.push(label_paint(ctx, row, label_rect)); 318 + } 319 + push_focus_ring( 320 + ctx, 321 + &mut paint, 322 + row_rect, 323 + ctx.theme.radius.none, 324 + live_focused, 325 + ); 326 + if let Some(target) = state.drop_target 327 + && target.anchor == row.id 328 + { 329 + paint.push(WidgetPaint::Surface { 330 + rect: drop_indicator_rect(row_rect, target.placement), 331 + fill: ctx.theme.colors.accent.step(Step12::SOLID), 332 + border: None, 333 + radius: ctx.theme.radius.none, 334 + elevation: None, 335 + }); 336 + } 337 + paint 338 + } 339 + 340 + struct RowOutcomes<'a> { 341 + activated: &'a mut Option<WidgetId>, 342 + double_activated: &'a mut Option<WidgetId>, 343 + drop_committed: &'a mut Option<(WidgetId, DropTarget)>, 344 + } 345 + 346 + fn apply_row_interaction( 347 + ctx: &mut FrameCtx<'_>, 348 + row: &VisibleRow, 349 + row_rect: LayoutRect, 350 + interaction: &crate::hit_test::Interaction, 351 + state: &mut TreeViewState, 352 + mode: TreeSelectionMode, 353 + outcomes: RowOutcomes<'_>, 354 + ) { 355 + let RowOutcomes { 356 + activated, 357 + double_activated, 358 + drop_committed, 359 + } = outcomes; 360 + if interaction.click() { 361 + update_selection(state, row.id, ctx.input.modifiers, mode); 362 + if activated.is_none() { 363 + *activated = Some(row.id); 364 + } 365 + state.focused = Some(row.id); 366 + ctx.focus.request_focus(row.id); 367 + } 368 + if interaction.double_click() && double_activated.is_none() { 369 + *double_activated = Some(row.id); 370 + } 371 + if interaction.drag_start() { 372 + state.drag_source = Some(row.id); 373 + } 374 + if interaction.drag_release() 375 + && let Some(src) = state.drag_source 376 + && let Some(target) = state.drop_target 377 + { 378 + if drop_committed.is_none() && src != target.anchor { 379 + *drop_committed = Some((src, target)); 380 + } 381 + state.drag_source = None; 382 + state.drop_target = None; 383 + } 384 + if state.drag_source.is_some() && interaction.hover() { 385 + let placement = ctx.input.pointer.map_or(DropPlacement::Into, |p| { 386 + placement_from_pointer(p.position, row_rect) 387 + }); 388 + state.drop_target = Some(DropTarget { 389 + anchor: row.id, 390 + placement, 391 + }); 392 + } 393 + } 394 + 395 + fn draw_disclosure( 396 + ctx: &mut FrameCtx<'_>, 397 + row: &VisibleRow, 398 + disclosure_rect: LayoutRect, 399 + state: &mut TreeViewState, 400 + ) -> Vec<WidgetPaint> { 401 + let disclosure_id = row.id.child(WidgetKey::new("disclosure")); 402 + let disclosure_interaction = ctx.interact(InteractDeclaration::new( 403 + disclosure_id, 404 + disclosure_rect, 405 + Sense::INTERACTIVE, 406 + )); 407 + if disclosure_interaction.click() { 408 + toggle_expanded(state, row.id); 409 + } 410 + vec![WidgetPaint::Mark { 411 + rect: disclosure_rect, 412 + kind: if state.expanded.contains(&row.id) { 413 + GlyphMark::DisclosureOpen 414 + } else { 415 + GlyphMark::DisclosureClosed 416 + }, 417 + color: ctx.theme.colors.text_secondary(), 418 + }] 419 + } 420 + 421 + fn label_paint(ctx: &FrameCtx<'_>, row: &VisibleRow, label_rect: LayoutRect) -> WidgetPaint { 422 + WidgetPaint::Label { 423 + rect: label_rect, 424 + text: LabelText::Key(row.label), 425 + color: ctx.theme.colors.text_primary(), 426 + role: ctx.theme.typography.body, 427 + } 428 + } 429 + 430 + fn draw_rename_editor( 431 + ctx: &mut FrameCtx<'_>, 432 + row_id: WidgetId, 433 + placeholder: StringKey, 434 + label_rect: LayoutRect, 435 + state: &mut TreeViewState, 436 + ) -> Vec<WidgetPaint> { 437 + let rename_id = row_id.child(WidgetKey::new("rename")); 438 + let response = show_text_input( 439 + ctx, 440 + TextInput { 441 + id: rename_id, 442 + rect: label_rect, 443 + placeholder, 444 + state: &mut state.rename_buffer, 445 + disabled: false, 446 + validator: AlwaysValid, 447 + }, 448 + &mut state.clipboard, 449 + ); 450 + if !ctx.is_focused(rename_id) { 451 + ctx.focus.request_focus(rename_id); 452 + } 453 + response.paint 454 + } 455 + 456 + fn placement_from_pointer(pointer: LayoutPos, row: LayoutRect) -> DropPlacement { 457 + let third = row.size.height.value() / 3.0; 458 + let local = pointer.y.value() - row.origin.y.value(); 459 + if local < third { 460 + DropPlacement::Before 461 + } else if local > 2.0 * third { 462 + DropPlacement::After 463 + } else { 464 + DropPlacement::Into 465 + } 466 + } 467 + 468 + fn drop_indicator_rect(row: LayoutRect, placement: DropPlacement) -> LayoutRect { 469 + let stripe = LayoutPx::new(2.0); 470 + match placement { 471 + DropPlacement::Before => { 472 + LayoutRect::new(row.origin, LayoutSize::new(row.size.width, stripe)) 473 + } 474 + DropPlacement::After => LayoutRect::new( 475 + LayoutPos::new( 476 + row.origin.x, 477 + LayoutPx::new(row.origin.y.value() + row.size.height.value() - stripe.value()), 478 + ), 479 + LayoutSize::new(row.size.width, stripe), 480 + ), 481 + DropPlacement::Into => row, 482 + } 483 + } 484 + 485 + fn row_fill( 486 + ctx: &FrameCtx<'_>, 487 + interaction: &crate::hit_test::Interaction, 488 + selected: bool, 489 + ) -> Color { 490 + let neutral = ctx.theme.colors.neutral; 491 + if selected { 492 + ctx.theme.colors.accent.step(Step12::HOVER_BG) 493 + } else if interaction.hover() { 494 + neutral.step(Step12::HOVER_BG) 495 + } else { 496 + Color::TRANSPARENT 497 + } 498 + } 499 + 500 + fn disclosure_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 501 + let size = 14.0; 502 + let pad = (row.size.height.value() - size) / 2.0; 503 + LayoutRect::new( 504 + LayoutPos::new( 505 + LayoutPx::new(row.origin.x.value() + indent.value() + 2.0), 506 + LayoutPx::new(row.origin.y.value() + pad), 507 + ), 508 + LayoutSize::new(LayoutPx::new(size), LayoutPx::new(size)), 509 + ) 510 + } 511 + 512 + fn label_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 513 + let chevron = 16.0 + 4.0; 514 + LayoutRect::new( 515 + LayoutPos::new( 516 + LayoutPx::new(row.origin.x.value() + indent.value() + chevron), 517 + row.origin.y, 518 + ), 519 + LayoutSize::new( 520 + LayoutPx::saturating_nonneg(row.size.width.value() - indent.value() - chevron), 521 + row.size.height, 522 + ), 523 + ) 524 + } 525 + 526 + fn update_selection( 527 + state: &mut TreeViewState, 528 + id: WidgetId, 529 + modifiers: ModifierMask, 530 + mode: TreeSelectionMode, 531 + ) { 532 + match mode { 533 + TreeSelectionMode::Single => { 534 + state.selection = BTreeSet::from([id]); 535 + } 536 + TreeSelectionMode::Multi => { 537 + if modifiers.contains(ModifierMask::CTRL) { 538 + if !state.selection.insert(id) { 539 + state.selection.remove(&id); 540 + } 541 + } else { 542 + state.selection = BTreeSet::from([id]); 543 + } 544 + } 545 + } 546 + } 547 + 548 + fn toggle_expanded(state: &mut TreeViewState, id: WidgetId) { 549 + if !state.expanded.insert(id) { 550 + state.expanded.remove(&id); 551 + } 552 + } 553 + 554 + fn handle_keyboard( 555 + ctx: &mut FrameCtx<'_>, 556 + visible: &[VisibleRow], 557 + state: &mut TreeViewState, 558 + activated: &mut Option<WidgetId>, 559 + ) { 560 + let in_tree_focus = ctx 561 + .focus 562 + .focused() 563 + .is_some_and(|f| visible.iter().any(|r| r.id == f)); 564 + if !in_tree_focus { 565 + return; 566 + } 567 + let focused = ctx.focus.focused(); 568 + let event = take_key( 569 + ctx.input, 570 + &[ 571 + TakeKey::named(NamedKey::ArrowUp), 572 + TakeKey::named(NamedKey::ArrowDown), 573 + TakeKey::named(NamedKey::ArrowLeft), 574 + TakeKey::named(NamedKey::ArrowRight), 575 + TakeKey::named(NamedKey::Home), 576 + TakeKey::named(NamedKey::End), 577 + TakeKey::named(NamedKey::Enter), 578 + TakeKey::named(NamedKey::Space), 579 + ], 580 + ); 581 + let Some(event) = event else { return }; 582 + let current = focused.and_then(|f| visible.iter().position(|r| r.id == f)); 583 + match event.code { 584 + KeyCode::Named(NamedKey::ArrowDown) => { 585 + if let Some(idx) = current 586 + && idx + 1 < visible.len() 587 + { 588 + ctx.focus.request_focus(visible[idx + 1].id); 589 + state.focused = Some(visible[idx + 1].id); 590 + } 591 + } 592 + KeyCode::Named(NamedKey::ArrowUp) => { 593 + if let Some(idx) = current 594 + && idx > 0 595 + { 596 + ctx.focus.request_focus(visible[idx - 1].id); 597 + state.focused = Some(visible[idx - 1].id); 598 + } 599 + } 600 + KeyCode::Named(NamedKey::ArrowRight) => { 601 + if let Some(idx) = current 602 + && visible[idx].has_children 603 + && !state.expanded.contains(&visible[idx].id) 604 + { 605 + state.expanded.insert(visible[idx].id); 606 + } 607 + } 608 + KeyCode::Named(NamedKey::ArrowLeft) => { 609 + if let Some(idx) = current 610 + && state.expanded.contains(&visible[idx].id) 611 + { 612 + state.expanded.remove(&visible[idx].id); 613 + } 614 + } 615 + KeyCode::Named(NamedKey::Home) => { 616 + if let Some(first) = visible.first() { 617 + ctx.focus.request_focus(first.id); 618 + state.focused = Some(first.id); 619 + } 620 + } 621 + KeyCode::Named(NamedKey::End) => { 622 + if let Some(last) = visible.last() { 623 + ctx.focus.request_focus(last.id); 624 + state.focused = Some(last.id); 625 + } 626 + } 627 + KeyCode::Named(NamedKey::Enter | NamedKey::Space) => { 628 + if let Some(idx) = current { 629 + let id = visible[idx].id; 630 + state.selection = BTreeSet::from([id]); 631 + if activated.is_none() { 632 + *activated = Some(id); 633 + } 634 + } 635 + } 636 + _ => {} 637 + } 638 + } 639 + 640 + #[cfg(test)] 641 + mod tests { 642 + use std::collections::BTreeSet; 643 + use std::sync::Arc; 644 + 645 + use super::{TreeNode, TreeSelectionMode, TreeView, TreeViewState, show_tree_view}; 646 + use crate::focus::FocusManager; 647 + use crate::frame::FrameCtx; 648 + use crate::hit_test::{HitFrame, HitState, resolve}; 649 + use crate::hotkey::HotkeyTable; 650 + use crate::input::{ 651 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 652 + PointerButtonMask, PointerSample, 653 + }; 654 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 655 + use crate::strings::{StringKey, StringTable}; 656 + use crate::theme::Theme; 657 + use crate::widget_id::{WidgetId, WidgetKey}; 658 + 659 + fn root_id(name: &'static str) -> WidgetId { 660 + WidgetId::ROOT.child(WidgetKey::new(name)) 661 + } 662 + 663 + fn sample_tree() -> Vec<TreeNode> { 664 + vec![ 665 + TreeNode::parent( 666 + root_id("origin"), 667 + StringKey::new("tree.origin"), 668 + vec![ 669 + TreeNode::leaf(root_id("plane_xy"), StringKey::new("tree.xy")), 670 + TreeNode::leaf(root_id("plane_yz"), StringKey::new("tree.yz")), 671 + ], 672 + ), 673 + TreeNode::leaf(root_id("sketch"), StringKey::new("tree.sketch")), 674 + ] 675 + } 676 + 677 + fn render( 678 + roots: &[TreeNode], 679 + state: &mut TreeViewState, 680 + focus: &mut FocusManager, 681 + snap: &mut InputSnapshot, 682 + prev: &HitState, 683 + ) -> (super::TreeViewResponse, HitState) { 684 + let theme = Arc::new(Theme::light()); 685 + let table = HotkeyTable::new(); 686 + let mut hits = HitFrame::new(); 687 + let rect = LayoutRect::new( 688 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 689 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 690 + ); 691 + let response = { 692 + let mut ctx = FrameCtx::new( 693 + theme, 694 + snap, 695 + focus, 696 + &table, 697 + StringTable::empty(), 698 + &mut hits, 699 + prev, 700 + ); 701 + show_tree_view( 702 + &mut ctx, 703 + TreeView::new( 704 + WidgetId::ROOT.child(WidgetKey::new("tree")), 705 + rect, 706 + roots, 707 + state, 708 + ), 709 + ) 710 + }; 711 + let next = resolve(prev, &hits, snap, focus.focused()); 712 + (response, next) 713 + } 714 + 715 + fn press(pos: LayoutPos) -> InputSnapshot { 716 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 717 + s.pointer = Some(PointerSample::new(pos)); 718 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 719 + s 720 + } 721 + 722 + fn release(pos: LayoutPos) -> InputSnapshot { 723 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 724 + s.pointer = Some(PointerSample::new(pos)); 725 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 726 + s 727 + } 728 + 729 + fn idle(pos: LayoutPos) -> InputSnapshot { 730 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 731 + s.pointer = Some(PointerSample::new(pos)); 732 + s 733 + } 734 + 735 + #[test] 736 + fn click_disclosure_toggles_expansion() { 737 + let roots = sample_tree(); 738 + let mut state = TreeViewState::default(); 739 + let mut focus = FocusManager::new(); 740 + let mut prev = HitState::new(); 741 + let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0)); 742 + [press(pos), release(pos), idle(pos)] 743 + .into_iter() 744 + .for_each(|mut snap| { 745 + let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 746 + prev = next; 747 + }); 748 + assert!(state.expanded.contains(&roots[0].id)); 749 + } 750 + 751 + #[test] 752 + fn click_label_selects_node_and_marks_active() { 753 + let roots = sample_tree(); 754 + let mut state = TreeViewState::default(); 755 + let mut focus = FocusManager::new(); 756 + let mut prev = HitState::new(); 757 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 758 + let mut last: Option<super::TreeViewResponse> = None; 759 + [press(click_pos), release(click_pos), idle(click_pos)] 760 + .into_iter() 761 + .for_each(|mut snap| { 762 + let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 763 + last = Some(response); 764 + prev = next; 765 + }); 766 + let Some(response) = last else { 767 + panic!("response missing") 768 + }; 769 + assert_eq!(response.activated, Some(roots[0].id)); 770 + assert!(state.selection.contains(&roots[0].id)); 771 + } 772 + 773 + #[test] 774 + fn arrow_down_moves_focus_to_next_visible() { 775 + let roots = sample_tree(); 776 + let mut state = TreeViewState::default(); 777 + state.expanded.insert(roots[0].id); 778 + let mut focus = FocusManager::new(); 779 + focus.register_focusable(roots[0].id); 780 + focus.request_focus(roots[0].id); 781 + focus.end_frame(); 782 + let prev = HitState::new(); 783 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 784 + snap.keys_pressed.push(KeyEvent::new( 785 + KeyCode::Named(NamedKey::ArrowDown), 786 + ModifierMask::NONE, 787 + )); 788 + let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 789 + assert_eq!(state.focused, Some(roots[0].children[0].id)); 790 + } 791 + 792 + #[test] 793 + fn arrow_right_expands_collapsed_parent() { 794 + let roots = sample_tree(); 795 + let mut state = TreeViewState::default(); 796 + let mut focus = FocusManager::new(); 797 + focus.register_focusable(roots[0].id); 798 + focus.request_focus(roots[0].id); 799 + focus.end_frame(); 800 + let prev = HitState::new(); 801 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 802 + snap.keys_pressed.push(KeyEvent::new( 803 + KeyCode::Named(NamedKey::ArrowRight), 804 + ModifierMask::NONE, 805 + )); 806 + let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 807 + assert!(state.expanded.contains(&roots[0].id)); 808 + } 809 + 810 + #[test] 811 + fn ctrl_click_in_multi_mode_toggles_selection_membership() { 812 + let roots = sample_tree(); 813 + let mut state = TreeViewState { 814 + selection: BTreeSet::from([roots[1].id]), 815 + ..TreeViewState::default() 816 + }; 817 + let mut focus = FocusManager::new(); 818 + let mut prev = HitState::new(); 819 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 820 + let theme = Arc::new(Theme::light()); 821 + let table = HotkeyTable::new(); 822 + let rect = LayoutRect::new( 823 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 824 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 825 + ); 826 + [press(click_pos), release(click_pos), idle(click_pos)] 827 + .into_iter() 828 + .for_each(|mut snap| { 829 + snap.modifiers = ModifierMask::CTRL; 830 + let mut hits = HitFrame::new(); 831 + { 832 + let mut ctx = FrameCtx::new( 833 + theme.clone(), 834 + &mut snap, 835 + &mut focus, 836 + &table, 837 + StringTable::empty(), 838 + &mut hits, 839 + &prev, 840 + ); 841 + let _ = show_tree_view( 842 + &mut ctx, 843 + TreeView::new( 844 + WidgetId::ROOT.child(WidgetKey::new("tree")), 845 + rect, 846 + &roots, 847 + &mut state, 848 + ) 849 + .mode(TreeSelectionMode::Multi), 850 + ); 851 + } 852 + prev = resolve(&prev, &hits, &snap, focus.focused()); 853 + }); 854 + assert!(state.selection.contains(&roots[0].id)); 855 + assert!(state.selection.contains(&roots[1].id)); 856 + } 857 + 858 + #[test] 859 + fn tree_registers_single_tab_stop_so_tab_does_not_walk_every_row() { 860 + let roots = sample_tree(); 861 + let mut state = TreeViewState::default(); 862 + state.expanded.insert(roots[0].id); 863 + let mut focus = FocusManager::new(); 864 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 865 + let prev = HitState::new(); 866 + let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev); 867 + let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect(); 868 + assert_eq!(stops, vec![roots[0].id]); 869 + } 870 + 871 + #[test] 872 + fn click_row_focuses_it_so_arrow_keys_engage() { 873 + let roots = sample_tree(); 874 + let mut state = TreeViewState::default(); 875 + let mut focus = FocusManager::new(); 876 + let mut prev = HitState::new(); 877 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 878 + [press(click_pos), release(click_pos), idle(click_pos)] 879 + .into_iter() 880 + .for_each(|mut snap| { 881 + let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 882 + prev = next; 883 + }); 884 + assert_eq!(focus.focused(), Some(roots[0].id)); 885 + } 886 + 887 + #[test] 888 + fn enter_with_renaming_clears_state_and_emits_committed() { 889 + use super::RenameCommit; 890 + use crate::widgets::text_input::TextInputState; 891 + let roots = sample_tree(); 892 + let mut state = TreeViewState { 893 + renaming: Some(roots[0].id), 894 + rename_buffer: TextInputState::from_text("renamed"), 895 + ..TreeViewState::default() 896 + }; 897 + let mut focus = FocusManager::new(); 898 + focus.register_focusable(roots[0].id); 899 + focus.request_focus(roots[0].id); 900 + focus.end_frame(); 901 + let prev = HitState::new(); 902 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 903 + snap.keys_pressed.push(KeyEvent::new( 904 + KeyCode::Named(NamedKey::Enter), 905 + ModifierMask::NONE, 906 + )); 907 + let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 908 + assert_eq!( 909 + response.rename_committed, 910 + Some(RenameCommit { 911 + id: roots[0].id, 912 + text: "renamed".into(), 913 + }), 914 + ); 915 + assert!(state.renaming.is_none()); 916 + } 917 + 918 + #[test] 919 + fn keyboard_enter_on_focused_row_emits_activated() { 920 + let roots = sample_tree(); 921 + let mut state = TreeViewState::default(); 922 + let mut focus = FocusManager::new(); 923 + focus.register_focusable(roots[0].id); 924 + focus.request_focus(roots[0].id); 925 + focus.end_frame(); 926 + let prev = HitState::new(); 927 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 928 + snap.keys_pressed.push(KeyEvent::new( 929 + KeyCode::Named(NamedKey::Enter), 930 + ModifierMask::NONE, 931 + )); 932 + let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 933 + assert_eq!(response.activated, Some(roots[0].id)); 934 + } 935 + }