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 );
}
}