ltk/
tree.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use crate::types::Point;
use crate::widget::{ LaidOutWidget, WidgetHandlers };

/// Hit test `pos` against the laid-out focusable widgets. Returns the
/// `flat_idx` of the topmost widget under the point, or `None` if the point
/// hits nothing focusable. Topmost-wins because layout pushes parents before
/// children — the reverse iteration order makes the visually-on-top widget
/// (drawn last) hit-tested first.
pub fn find_widget_at<Msg: Clone>(
	widget_rects: &[ LaidOutWidget<Msg> ],
	pos:          Point,
) -> Option<usize>
{
	for w in widget_rects.iter().rev()
	{
		// `paint_rect` is clamped to the viewport by Scroll / Viewport, so a
		// row scrolled half out of view is only hit on its visible part.
		if w.rect.contains( pos ) && w.paint_rect.contains( pos ) { return Some( w.flat_idx ); }
	}
	None
}

/// O(N) lookup of a single widget by `flat_idx`. N is the number of
/// *focusable leaves*, not the size of the [`crate::widget::Element`] tree.
/// If the hot path ever needs O(1), swap the per-surface `widget_rects`
/// slice for a `Vec` + a `HashMap<usize, usize>` index.
pub fn find_widget<Msg: Clone>(
	widget_rects: &[ LaidOutWidget<Msg> ],
	flat_idx:     usize,
) -> Option<&LaidOutWidget<Msg>>
{
	widget_rects.iter().find( |w| w.flat_idx == flat_idx )
}

/// Convenience wrapper around [`find_widget`] that returns just the handler
/// snapshot — what most input dispatch sites actually want.
pub fn find_handlers<Msg: Clone>(
	widget_rects: &[ LaidOutWidget<Msg> ],
	flat_idx:     usize,
) -> Option<&WidgetHandlers<Msg>>
{
	find_widget( widget_rects, flat_idx ).map( |w| &w.handlers )
}

/// Compute the next focusable widget for Tab / Shift+Tab navigation.
/// Returns the `flat_idx` of the next keyboard-focusable entry after the one
/// matching `current` (or wrapping around). `reverse` flips the direction
/// (Shift+Tab). Returns `None` when no entry has
/// [`LaidOutWidget::keyboard_focusable`] set — typical for a surface that only
/// hosts hit-testable chrome (e.g. a row of `WindowButton`s).
///
/// When `current` is `None` (no widget currently focused) the result is the
/// first or last keyboard-focusable widget depending on direction — matching
/// how desktop toolkits behave when Tab is pressed in an unfocused window.
///
/// Non-focusable interactive entries (chrome such as `WindowButton`) live in
/// the same `widget_rects` slice so pointer/touch hit testing finds them, but
/// are skipped here so they don't steal Tab focus from window content.
pub fn next_focusable_index<Msg: Clone>(
	widget_rects: &[ LaidOutWidget<Msg> ],
	current:      Option<usize>,
	reverse:      bool,
) -> Option<usize>
{
	// Project the slice down to just the keyboard-focusable entries. Tab
	// cycles over this projection only — pointer-only chrome sits in
	// `widget_rects` for hit testing but never receives keyboard focus.
	let focusables: Vec<usize> = widget_rects
		.iter()
		.filter( |w| w.keyboard_focusable )
		.map( |w| w.flat_idx )
		.collect();
	let n = focusables.len();
	if n == 0 { return None; }

	let current_pos = current.and_then( |fi| focusables.iter().position( |&i| i == fi ) );

	let next = if reverse
	{
		current_pos
			.map( |pos| focusables[ ( pos + n - 1 ) % n ] )
			.unwrap_or( focusables[ n - 1 ] )
	} else {
		current_pos
			.map( |pos| focusables[ ( pos + 1 ) % n ] )
			.unwrap_or( focusables[ 0 ] )
	};
	Some( next )
}

#[ cfg( test ) ]
mod tests
{
	use super::*;
	use crate::types::Rect;

	fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect
	{
		Rect { x, y, width: w, height: h }
	}

	fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()>
	{
		LaidOutWidget
		{
			rect,
			flat_idx:           idx,
			id:                 None,
			paint_rect:         paint,
			handlers:           WidgetHandlers::None,
			keyboard_focusable: true,
			focus_on_press:     true,
			cursor:             crate::types::CursorShape::Default,
			tooltip:            None,
			accessible_label:   None,
			is_live_region:     false,
		}
	}

	#[ test ]
	fn find_widget_at_ignores_the_part_of_a_row_scrolled_out_of_view()
	{
		// A row whose top 60 px sit above a viewport starting at y = 100.
		let row = lw( 3, r( 0.0, 40.0, 200.0, 80.0 ), r( 0.0, 100.0, 200.0, 20.0 ) );
		let widgets = [ row ];
		assert_eq!( find_widget_at( &widgets, Point { x: 50.0, y: 70.0 } ), None );
		assert_eq!( find_widget_at( &widgets, Point { x: 50.0, y: 110.0 } ), Some( 3 ) );
	}

	#[ test ]
	fn find_widget_at_keeps_the_full_rect_when_paint_rect_encloses_it()
	{
		let button = lw( 1, r( 10.0, 10.0, 100.0, 40.0 ), r( 8.0, 8.0, 104.0, 44.0 ) );
		let widgets = [ button ];
		assert_eq!( find_widget_at( &widgets, Point { x: 10.0, y: 10.0 } ), Some( 1 ) );
		assert_eq!( find_widget_at( &widgets, Point { x: 9.0, y: 9.0 } ), None );
	}
}