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

@@ -1,21 +1,37 @@
use std::error::Error;
use ruin_ui::{
Color, Edges, Element, SceneSnapshot, TextAlign, TextFontFamily, TextSpan, TextSpanSlant,
TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec, layout_scene_with_text_system,
Color, Edges, Element, ElementId, LayoutSnapshot, PointerRouter, TextAlign, TextFontFamily,
TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec,
layout_snapshot_with_text_system,
};
use ruin_ui_platform_wayland::WaylandWindow;
use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer};
const BODY_ONE: &str = "Paragraph widgets are the next useful layer above raw text leaves. They should be able to wrap naturally inside containers, respect alignment, clamp to a maximum number of lines when appropriate, and participate in layout without forcing every example to become a custom text experiment. That gets us closer to real application surfaces instead of just proving that glyphs can reach the screen.";
const BODY_TWO: &str = "This demo keeps the overall layout mostly static while still responding to window resizing. The centered title, the body copy, and the clamped sidebar notes all use the same retained layout pipeline, but they exercise different paragraph semantics. It should be a much less exhausting place to look at text than the reactive dashboard stress test.";
const WHY_CARD_ID: ElementId = ElementId::new(1);
const CALM_CARD_ID: ElementId = ElementId::new(2);
const NEXT_CARD_ID: ElementId = ElementId::new(3);
const QUOTE_CARD_ID: ElementId = ElementId::new(4);
const NOTE_CARD_ID: ElementId = ElementId::new(5);
const STATUS_CARD_ID: ElementId = ElementId::new(6);
#[derive(Clone, Default)]
struct SidebarCardOptions {
align: Option<TextAlign>,
font_family: Option<TextFontFamily>,
max_lines: Option<usize>,
}
#[ruin_runtime::main]
fn main() -> Result<(), Box<dyn Error>> {
let mut viewport = UiSize::new(1040.0, 760.0);
let mut version = 1_u64;
let mut text_system = TextSystem::new();
let mut scene = build_scene(viewport, version, &mut text_system);
let mut pointer_router = PointerRouter::new();
let mut hovered_card = None;
let mut snapshot = build_snapshot(viewport, version, hovered_card, &mut text_system);
let mut window = WaylandWindow::open(
WindowSpec::new("RUIN paragraph demo")
@@ -33,16 +49,34 @@ fn main() -> Result<(), Box<dyn Error>> {
while window.is_running() {
window.dispatch()?;
let mut needs_scene_rebuild = false;
for event in window.drain_pointer_events() {
let _ = pointer_router.route(&snapshot.interaction_tree, event);
let next_hover = pointer_router
.hovered_target()
.and_then(|target| target.element_id)
.filter(|id| is_hoverable_card(*id));
if next_hover != hovered_card {
hovered_card = next_hover;
version = version.wrapping_add(1);
needs_scene_rebuild = true;
}
}
if needs_scene_rebuild {
snapshot = build_snapshot(viewport, version, hovered_card, &mut text_system);
window.request_redraw();
}
if let Some(frame) = window.prepare_frame() {
if frame.resized {
renderer.resize(frame.width, frame.height);
viewport = UiSize::new(frame.width as f32, frame.height as f32);
version = version.wrapping_add(1);
scene = build_scene(viewport, version, &mut text_system);
snapshot = build_snapshot(viewport, version, hovered_card, &mut text_system);
window.request_redraw();
}
match renderer.render(&scene) {
match renderer.render(&snapshot.scene) {
Ok(()) => {}
Err(RenderError::Lost | RenderError::Outdated) => {
renderer.resize(frame.width, frame.height);
@@ -58,12 +92,17 @@ fn main() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn build_scene(viewport: UiSize, version: u64, text_system: &mut TextSystem) -> SceneSnapshot {
let tree = build_document_tree(viewport);
layout_scene_with_text_system(version, viewport, &tree, text_system)
fn build_snapshot(
viewport: UiSize,
version: u64,
hovered_card: Option<ElementId>,
text_system: &mut TextSystem,
) -> LayoutSnapshot {
let tree = build_document_tree(viewport, hovered_card);
layout_snapshot_with_text_system(version, viewport, &tree, text_system)
}
fn build_document_tree(viewport: UiSize) -> Element {
fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Element {
let gutter = (viewport.width * 0.025).clamp(18.0, 30.0);
let sidebar_width = (viewport.width * 0.28).clamp(220.0, 320.0);
@@ -113,16 +152,32 @@ fn build_document_tree(viewport: UiSize) -> Element {
.flex(1.0)
.gap(gutter)
.children([
text_card("Why paragraphs matter", BODY_ONE, gutter),
text_card("Calmer inspection surface", BODY_TWO, gutter),
text_card(
WHY_CARD_ID,
hovered_card,
"Why paragraphs matter",
BODY_ONE,
gutter,
),
text_card(
CALM_CARD_ID,
hovered_card,
"Calmer inspection surface",
BODY_TWO,
gutter,
),
Element::column()
.id(NEXT_CARD_ID)
.padding(Edges::all(gutter))
.gap(gutter * 0.45)
.background(Color::rgb(0x1A, 0x22, 0x31))
.background(card_background(NEXT_CARD_ID, hovered_card))
.children([
Element::paragraph(
"Next direction",
TextStyle::new(20.0, Color::rgb(0xF5, 0xF7, 0xFB))
TextStyle::new(
20.0,
card_title_color(NEXT_CARD_ID, hovered_card),
)
.with_line_height(26.0),
),
Element::rich_paragraph(
@@ -151,36 +206,50 @@ fn build_document_tree(viewport: UiSize) -> Element {
.gap(gutter)
.children([
sidebar_card(
QUOTE_CARD_ID,
hovered_card,
"Centered pull quote",
"“A retained layout tree is only really convincing once text can participate in it naturally.”",
gutter,
Some(TextAlign::Center),
Some(TextFontFamily::Serif),
None,
SidebarCardOptions {
align: Some(TextAlign::Center),
font_family: Some(TextFontFamily::Serif),
max_lines: None,
},
),
rich_sidebar_card(gutter),
rich_sidebar_card(hovered_card, gutter),
sidebar_card(
STATUS_CARD_ID,
hovered_card,
"Status",
"Static layout, responsive resize, paragraph wrapping, centered headings, and line clamping all share the same UI pipeline now.",
gutter,
Some(TextAlign::End),
None,
None,
SidebarCardOptions {
align: Some(TextAlign::End),
..SidebarCardOptions::default()
},
),
]),
]),
])
}
fn text_card(title: &str, body: &str, gutter: f32) -> Element {
fn text_card(
id: ElementId,
hovered_card: Option<ElementId>,
title: &str,
body: &str,
gutter: f32,
) -> Element {
Element::column()
.id(id)
.padding(Edges::all(gutter))
.gap(gutter * 0.45)
.background(Color::rgb(0x18, 0x20, 0x2F))
.background(card_background(id, hovered_card))
.children([
Element::paragraph(
title,
TextStyle::new(24.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(30.0),
TextStyle::new(24.0, card_title_color(id, hovered_card)).with_line_height(30.0),
),
Element::paragraph(
body,
@@ -190,46 +259,49 @@ fn text_card(title: &str, body: &str, gutter: f32) -> Element {
}
fn sidebar_card(
id: ElementId,
hovered_card: Option<ElementId>,
title: &str,
body: &str,
gutter: f32,
align: Option<TextAlign>,
font_family: Option<TextFontFamily>,
max_lines: Option<usize>,
options: SidebarCardOptions,
) -> Element {
let mut body_style = TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0);
if let Some(align) = align {
if let Some(align) = options.align {
body_style = body_style.with_align(align);
}
if let Some(font_family) = font_family {
if let Some(font_family) = options.font_family {
body_style = body_style.with_font_family(font_family);
}
if let Some(max_lines) = max_lines {
if let Some(max_lines) = options.max_lines {
body_style = body_style.with_max_lines(max_lines);
}
Element::column()
.id(id)
.padding(Edges::all(gutter * 0.9))
.gap(gutter * 0.35)
.background(Color::rgb(0x1C, 0x24, 0x34))
.background(card_background(id, hovered_card))
.children([
Element::paragraph(
title,
TextStyle::new(18.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(24.0),
TextStyle::new(18.0, card_title_color(id, hovered_card)).with_line_height(24.0),
),
Element::paragraph(body, body_style),
])
}
fn rich_sidebar_card(gutter: f32) -> Element {
fn rich_sidebar_card(hovered_card: Option<ElementId>, gutter: f32) -> Element {
Element::column()
.id(NOTE_CARD_ID)
.padding(Edges::all(gutter * 0.9))
.gap(gutter * 0.35)
.background(Color::rgb(0x1C, 0x24, 0x34))
.background(card_background(NOTE_CARD_ID, hovered_card))
.children([
Element::paragraph(
"Clamped note",
TextStyle::new(18.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(24.0),
TextStyle::new(18.0, card_title_color(NOTE_CARD_ID, hovered_card))
.with_line_height(24.0),
),
Element::rich_paragraph(
[
@@ -255,3 +327,30 @@ fn rich_sidebar_card(gutter: f32) -> Element {
),
])
}
fn is_hoverable_card(id: ElementId) -> bool {
matches!(
id,
WHY_CARD_ID | CALM_CARD_ID | NEXT_CARD_ID | QUOTE_CARD_ID | NOTE_CARD_ID | STATUS_CARD_ID
)
}
fn card_background(id: ElementId, hovered_card: Option<ElementId>) -> Color {
if hovered_card == Some(id) {
return Color::rgb(0x24, 0x31, 0x46);
}
match id {
WHY_CARD_ID | CALM_CARD_ID => Color::rgb(0x18, 0x20, 0x2F),
NEXT_CARD_ID => Color::rgb(0x1A, 0x22, 0x31),
QUOTE_CARD_ID | NOTE_CARD_ID | STATUS_CARD_ID => Color::rgb(0x1C, 0x24, 0x34),
_ => Color::rgb(0x18, 0x20, 0x2F),
}
}
fn card_title_color(id: ElementId, hovered_card: Option<ElementId>) -> Color {
if hovered_card == Some(id) {
return Color::rgb(0xFF, 0xE4, 0x9A);
}
Color::rgb(0xF4, 0xF7, 0xFF)
}

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

View File

@@ -10,10 +10,10 @@ use raw_window_handle::{
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle,
RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
};
use ruin_ui::{UiSize, WindowSpec};
use ruin_ui::{Point, PointerButton, PointerEvent, PointerEventKind, UiSize, WindowSpec};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_compositor, wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
use wayland_client::protocol::{wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
#[derive(Clone)]
@@ -67,10 +67,14 @@ struct State {
_xdg_surface: xdg_surface::XdgSurface,
_toplevel: xdg_toplevel::XdgToplevel,
_wm_base: xdg_wm_base::XdgWmBase,
_seat: wl_seat::WlSeat,
pointer: Option<wl_pointer::WlPointer>,
current_size: (u32, u32),
configured: bool,
pending_size: Option<(u32, u32)>,
needs_redraw: bool,
pointer_position: Option<Point>,
pending_pointer_events: Vec<PointerEvent>,
}
impl State {
@@ -86,6 +90,7 @@ impl WaylandWindow {
let qh = event_queue.handle();
let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 4..=6, ())?;
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=9, ())?;
let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
let surface = compositor.create_surface(&qh, ());
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
@@ -127,10 +132,14 @@ impl WaylandWindow {
_xdg_surface: xdg_surface,
_toplevel: toplevel,
_wm_base: wm_base,
_seat: seat,
pointer: None,
current_size: (initial_width, initial_height),
configured: false,
pending_size: None,
needs_redraw: false,
pointer_position: None,
pending_pointer_events: Vec::new(),
},
})
}
@@ -273,6 +282,10 @@ impl WaylandWindow {
self.state.request_redraw();
}
pub fn drain_pointer_events(&mut self) -> Vec<PointerEvent> {
std::mem::take(&mut self.state.pending_pointer_events)
}
pub fn prepare_frame(&mut self) -> Option<FrameRequest> {
if !self.state.configured {
return None;
@@ -316,6 +329,97 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
delegate_noop!(State: ignore wl_compositor::WlCompositor);
delegate_noop!(State: ignore wl_surface::WlSurface);
impl Dispatch<wl_seat::WlSeat, ()> for State {
fn event(
state: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_data: &(),
_conn: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities { capabilities } = event {
let WEnum::Value(capabilities) = capabilities else {
return;
};
if capabilities.contains(wl_seat::Capability::Pointer) {
if state.pointer.is_none() {
state.pointer = Some(seat.get_pointer(qh, ()));
}
} else {
state.pointer = None;
state.pointer_position = None;
}
}
}
}
impl Dispatch<wl_pointer::WlPointer, ()> for State {
fn event(
state: &mut Self,
_pointer: &wl_pointer::WlPointer,
event: wl_pointer::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
match event {
wl_pointer::Event::Enter {
surface_x,
surface_y,
..
}
| wl_pointer::Event::Motion {
surface_x,
surface_y,
..
} => {
let position = Point::new(surface_x as f32, surface_y as f32);
state.pointer_position = Some(position);
state.pending_pointer_events.push(PointerEvent::new(
0,
position,
PointerEventKind::Move,
));
}
wl_pointer::Event::Leave { .. } => {
let position = state.pointer_position.unwrap_or(Point::new(-1.0, -1.0));
state.pending_pointer_events.push(PointerEvent::new(
0,
position,
PointerEventKind::LeaveWindow,
));
state.pointer_position = None;
}
wl_pointer::Event::Button {
button,
state: button_state,
..
} => {
let Some(position) = state.pointer_position else {
return;
};
if button != 0x110 {
return;
}
let kind = match button_state {
WEnum::Value(wl_pointer::ButtonState::Pressed) => PointerEventKind::Down {
button: PointerButton::Primary,
},
WEnum::Value(wl_pointer::ButtonState::Released) => PointerEventKind::Up {
button: PointerButton::Primary,
},
WEnum::Value(_) | WEnum::Unknown(_) => return,
};
state
.pending_pointer_events
.push(PointerEvent::new(0, position, kind));
}
_ => {}
}
}
}
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
fn event(
_state: &mut Self,