Pointer
This commit is contained in:
226
lib/ui/src/interaction.rs
Normal file
226
lib/ui/src/interaction.rs
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user