Another project
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}