This commit is contained in:
2026-03-20 20:28:48 -04:00
parent f71e03317d
commit d79a3bb728
9 changed files with 702 additions and 52 deletions

View File

@@ -68,6 +68,17 @@ fn log_platform_event(event: &PlatformEvent) {
PlatformEvent::Closed { window_id } => {
tracing::info!(event = "window_closed", window_id = window_id.raw());
}
PlatformEvent::Pointer { window_id, event } => {
tracing::debug!(
event = "pointer_event",
window_id = window_id.raw(),
pointer_id = event.pointer_id,
x = event.position.x,
y = event.position.y,
?event.kind,
"pointer event received"
);
}
PlatformEvent::CloseRequested { window_id } => {
tracing::info!(
event = "close_requested",

226
lib/ui/src/interaction.rs Normal file
View File

@@ -0,0 +1,226 @@
use crate::layout::{HitTarget, InteractionTree};
use crate::scene::Point;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PointerButton {
Primary,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PointerEventKind {
Move,
Down { button: PointerButton },
Up { button: PointerButton },
LeaveWindow,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointerEvent {
pub pointer_id: u32,
pub position: Point,
pub kind: PointerEventKind,
}
impl PointerEvent {
pub const fn new(pointer_id: u32, position: Point, kind: PointerEventKind) -> Self {
Self {
pointer_id,
position,
kind,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RoutedPointerEventKind {
Enter,
Leave,
Move,
Down { button: PointerButton },
Up { button: PointerButton },
}
#[derive(Clone, Debug, PartialEq)]
pub struct RoutedPointerEvent {
pub kind: RoutedPointerEventKind,
pub target: HitTarget,
pub pointer_id: u32,
pub position: Point,
}
#[derive(Default)]
pub struct PointerRouter {
hovered: Option<HitTarget>,
pressed: Option<HitTarget>,
}
impl PointerRouter {
pub fn new() -> Self {
Self::default()
}
pub fn hovered_target(&self) -> Option<&HitTarget> {
self.hovered.as_ref()
}
pub fn pressed_target(&self) -> Option<&HitTarget> {
self.pressed.as_ref()
}
pub fn route(
&mut self,
interaction_tree: &InteractionTree,
event: PointerEvent,
) -> Vec<RoutedPointerEvent> {
let hit_target = match event.kind {
PointerEventKind::LeaveWindow => None,
_ => interaction_tree.hit_test(event.position),
};
let mut routed = Vec::new();
if self.hovered != hit_target {
if let Some(previous) = self.hovered.take() {
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Leave,
target: previous,
pointer_id: event.pointer_id,
position: event.position,
});
}
if let Some(target) = hit_target.clone() {
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Enter,
target: target.clone(),
pointer_id: event.pointer_id,
position: event.position,
});
self.hovered = Some(target);
}
}
match event.kind {
PointerEventKind::Move => {
if let Some(target) = hit_target {
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Move,
target,
pointer_id: event.pointer_id,
position: event.position,
});
}
}
PointerEventKind::Down { button } => {
if let Some(target) = hit_target {
self.pressed = Some(target.clone());
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Down { button },
target,
pointer_id: event.pointer_id,
position: event.position,
});
}
}
PointerEventKind::Up { button } => {
if let Some(target) = self.pressed.take().or(hit_target) {
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Up { button },
target,
pointer_id: event.pointer_id,
position: event.position,
});
}
}
PointerEventKind::LeaveWindow => {}
}
routed
}
}
#[cfg(test)]
mod tests {
use super::{
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEventKind,
};
use crate::layout::{InteractionTree, LayoutNode, LayoutPath};
use crate::scene::{Point, Rect};
use crate::tree::ElementId;
fn interaction_tree() -> InteractionTree {
InteractionTree {
root: LayoutNode {
path: LayoutPath::root(),
element_id: None,
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
pointer_events: false,
children: vec![
LayoutNode {
path: LayoutPath::root().child(0),
element_id: Some(ElementId::new(1)),
rect: Rect::new(0.0, 0.0, 120.0, 120.0),
pointer_events: true,
children: Vec::new(),
},
LayoutNode {
path: LayoutPath::root().child(1),
element_id: Some(ElementId::new(2)),
rect: Rect::new(80.0, 0.0, 120.0, 120.0),
pointer_events: true,
children: Vec::new(),
},
],
},
}
}
#[test]
fn router_emits_enter_and_move_for_new_hover_target() {
let mut router = PointerRouter::new();
let routed = router.route(
&interaction_tree(),
PointerEvent::new(0, Point::new(20.0, 20.0), PointerEventKind::Move),
);
assert_eq!(routed.len(), 2);
assert_eq!(routed[0].kind, RoutedPointerEventKind::Enter);
assert_eq!(routed[1].kind, RoutedPointerEventKind::Move);
assert_eq!(routed[1].target.element_id, Some(ElementId::new(1)));
}
#[test]
fn router_prefers_pressed_target_on_pointer_up() {
let mut router = PointerRouter::new();
let tree = interaction_tree();
let _ = router.route(
&tree,
PointerEvent::new(
0,
Point::new(20.0, 20.0),
PointerEventKind::Down {
button: PointerButton::Primary,
},
),
);
let routed = router.route(
&tree,
PointerEvent::new(
0,
Point::new(160.0, 20.0),
PointerEventKind::Up {
button: PointerButton::Primary,
},
),
);
assert!(routed.iter().any(|event| matches!(
event.kind,
RoutedPointerEventKind::Up {
button: PointerButton::Primary
}
)));
assert_eq!(
routed.last().unwrap().target.element_id,
Some(ElementId::new(1))
);
}
}

View File

@@ -1,6 +1,6 @@
use crate::scene::{Rect, SceneSnapshot, UiSize};
use crate::text::TextSystem;
use crate::tree::{Edges, Element, FlexDirection};
use crate::tree::{Edges, Element, ElementId, FlexDirection};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new();
@@ -13,8 +13,74 @@ pub fn layout_scene_with_text_system(
root: &Element,
text_system: &mut TextSystem,
) -> SceneSnapshot {
layout_snapshot_with_text_system(version, logical_size, root, text_system).scene
}
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutSnapshot {
pub scene: SceneSnapshot,
pub interaction_tree: InteractionTree,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LayoutPath(Vec<u32>);
impl LayoutPath {
pub fn root() -> Self {
Self(Vec::new())
}
pub fn segments(&self) -> &[u32] {
&self.0
}
pub(crate) fn child(&self, index: usize) -> Self {
let mut segments = self.0.clone();
segments.push(index as u32);
Self(segments)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct HitTarget {
pub path: LayoutPath,
pub element_id: Option<ElementId>,
pub rect: Rect,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutNode {
pub path: LayoutPath,
pub element_id: Option<ElementId>,
pub rect: Rect,
pub pointer_events: bool,
pub children: Vec<LayoutNode>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct InteractionTree {
pub root: LayoutNode,
}
impl InteractionTree {
pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
hit_test_node(&self.root, point)
}
}
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
let mut text_system = TextSystem::new();
layout_snapshot_with_text_system(version, logical_size, root, &mut text_system)
}
pub fn layout_snapshot_with_text_system(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
) -> LayoutSnapshot {
let mut scene = SceneSnapshot::new(version, logical_size);
layout_element(
let interaction_root = layout_element(
root,
Rect::new(
0.0,
@@ -22,20 +88,35 @@ pub fn layout_scene_with_text_system(
logical_size.width.max(0.0),
logical_size.height.max(0.0),
),
LayoutPath::root(),
&mut scene,
text_system,
);
scene
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
},
}
}
fn layout_element(
element: &Element,
rect: Rect,
path: LayoutPath,
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
) {
) -> LayoutNode {
let mut interaction = LayoutNode {
path,
element_id: element.id,
rect,
pointer_events: element.style.pointer_events,
children: Vec::new(),
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return;
return interaction;
}
if let Some(color) = element.style.background {
@@ -51,16 +132,16 @@ fn layout_element(
text.style.clone().with_bounds(content.size),
));
}
return;
return interaction;
}
if element.children.is_empty() {
return;
return interaction;
}
let content = inset_rect(rect, element.style.padding);
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return;
return interaction;
}
let gap_count = element.children.len().saturating_sub(1) as f32;
@@ -107,7 +188,7 @@ fn layout_element(
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
for (child, measured) in element.children.iter().zip(measured_children) {
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
@@ -124,9 +205,39 @@ fn layout_element(
child_main.max(0.0),
measured.cross,
);
layout_element(child, child_rect, scene, text_system);
interaction.children.push(layout_element(
child,
child_rect,
interaction.path.child(index),
scene,
text_system,
));
cursor += child_main.max(0.0) + element.style.gap;
}
interaction
}
fn hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<HitTarget> {
if !node.rect.contains(point) {
return None;
}
for child in node.children.iter().rev() {
if let Some(hit) = hit_test_node(child, point) {
return Some(hit);
}
}
if node.pointer_events {
return Some(HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
});
}
None
}
#[derive(Clone, Copy, Debug)]
@@ -337,10 +448,10 @@ fn child_rect(
#[cfg(test)]
mod tests {
use super::layout_scene;
use crate::scene::{Color, DisplayItem, Quad, Rect, UiSize};
use super::{layout_scene, layout_snapshot};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element};
use crate::tree::{Edges, Element, ElementId};
#[test]
fn row_layout_apportions_fixed_and_flex_children() {
@@ -464,4 +575,54 @@ mod tests {
.any(|item| matches!(item, DisplayItem::Text(_)))
);
}
#[test]
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
let root = Element::column()
.id(ElementId::new(1))
.children([Element::new()
.id(ElementId::new(2))
.height(80.0)
.background(Color::rgb(0x22, 0x33, 0x44))
.child(
Element::new()
.id(ElementId::new(3))
.width(120.0)
.height(40.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
)]);
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
let hit = snapshot
.interaction_tree
.hit_test(Point::new(20.0, 20.0))
.expect("point should hit nested child");
assert_eq!(hit.element_id, Some(ElementId::new(3)));
assert_eq!(hit.path.segments(), &[0, 0]);
}
#[test]
fn interaction_tree_skips_pointer_disabled_node_and_falls_back_to_parent() {
let root = Element::column().id(ElementId::new(1)).child(
Element::new()
.id(ElementId::new(2))
.height(80.0)
.background(Color::rgb(0x22, 0x33, 0x44))
.child(
Element::new()
.id(ElementId::new(3))
.width(120.0)
.height(40.0)
.pointer_events(false)
.background(Color::rgb(0x44, 0x55, 0x66)),
),
);
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
let hit = snapshot
.interaction_tree
.hit_test(Point::new(20.0, 20.0))
.expect("point should still hit parent");
assert_eq!(hit.element_id, Some(ElementId::new(2)));
}
}

View File

@@ -10,6 +10,7 @@ pub(crate) mod trace_targets {
pub const SCENE: &str = "ruin_ui::scene";
}
mod interaction;
mod layout;
mod platform;
mod runtime;
@@ -18,6 +19,14 @@ mod text;
mod tree;
mod window;
pub use interaction::{
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind,
};
pub use layout::{
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, layout_snapshot,
layout_snapshot_with_text_system,
};
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime, start_headless};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
@@ -29,7 +38,7 @@ pub use text::{
TextAlign, TextFontFamily, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem,
TextWrap,
};
pub use tree::{Edges, Element, FlexDirection, Style};
pub use tree::{Edges, Element, ElementId, FlexDirection, Style};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
};

View File

@@ -11,6 +11,7 @@ use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
use tracing::{debug, info};
use crate::interaction::PointerEvent;
use crate::scene::{SceneSnapshot, UiSize};
use crate::trace_targets;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
@@ -53,6 +54,10 @@ pub enum PlatformEvent {
scene_version: u64,
item_count: usize,
},
Pointer {
window_id: WindowId,
event: PointerEvent,
},
CloseRequested {
window_id: WindowId,
},

View File

@@ -44,6 +44,13 @@ impl Rect {
size: UiSize::new(width, height),
}
}
pub fn contains(self, point: Point) -> bool {
point.x >= self.origin.x
&& point.y >= self.origin.y
&& point.x < self.origin.x + self.size.width
&& point.y < self.origin.y + self.size.height
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]

View File

@@ -1,6 +1,19 @@
use crate::scene::Color;
use crate::text::{TextSpan, TextStyle, TextWrap};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ElementId(u64);
impl ElementId {
pub const fn new(raw: u64) -> Self {
Self(raw)
}
pub const fn raw(self) -> u64 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FlexDirection {
Row,
@@ -51,6 +64,7 @@ pub struct Style {
pub gap: f32,
pub padding: Edges,
pub background: Option<Color>,
pub pointer_events: bool,
}
impl Default for Style {
@@ -63,6 +77,7 @@ impl Default for Style {
gap: 0.0,
padding: Edges::ZERO,
background: None,
pointer_events: true,
}
}
}
@@ -81,6 +96,7 @@ pub(crate) struct TextNode {
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
pub id: Option<ElementId>,
pub style: Style,
pub children: Vec<Element>,
content: ElementContent,
@@ -89,6 +105,7 @@ pub struct Element {
impl Element {
pub fn new() -> Self {
Self {
id: None,
style: Style::default(),
children: Vec::new(),
content: ElementContent::Container,
@@ -101,6 +118,7 @@ impl Element {
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
Self {
id: None,
style: Style::default(),
children: Vec::new(),
content: ElementContent::Text(TextNode {
@@ -161,6 +179,16 @@ impl Element {
self
}
pub fn id(mut self, id: ElementId) -> Self {
self.id = Some(id);
self
}
pub fn pointer_events(mut self, pointer_events: bool) -> Self {
self.style.pointer_events = pointer_events;
self
}
pub fn child(mut self, child: Element) -> Self {
self.assert_container();
self.children.push(child);