More text improvements, performance enhancements, input handling, text selection, wl cursors

This commit is contained in:
2026-03-20 22:24:29 -04:00
parent d79a3bb728
commit 423df4ae1f
15 changed files with 2458 additions and 265 deletions

View File

@@ -7,4 +7,5 @@ edition = "2024"
ruin_runtime = { package = "ruin-runtime", path = "../../lib/runtime" }
ruin_ui = { path = "../../lib/ui" }
ruin_ui_platform_wayland = { path = "../../lib/ui_platform_wayland" }
ruin_ui_renderer_wgpu = { path = "../../lib/ui_renderer_wgpu" }
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }

View File

@@ -1,12 +1,18 @@
use std::error::Error;
use std::process::Command;
use std::time::Instant;
use ruin_ui::{
Color, Edges, Element, ElementId, LayoutSnapshot, PointerRouter, TextAlign, TextFontFamily,
TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec,
Color, CursorIcon, DisplayItem, Edges, Element, ElementId, InteractionTree, LayoutSnapshot,
PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad,
RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan,
TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec, WindowUpdate,
layout_snapshot_with_text_system,
};
use ruin_ui_platform_wayland::WaylandWindow;
use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer};
use ruin_ui_platform_wayland::start_wayland_ui;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
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.";
@@ -16,6 +22,12 @@ 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);
const HERO_TITLE_ID: ElementId = ElementId::new(101);
const HERO_BODY_ID: ElementId = ElementId::new(102);
const RUST_LINK_ID: ElementId = ElementId::new(103);
const DEMO_SELECTION_STYLE: TextSelectionStyle =
TextSelectionStyle::new(Color::rgba(0x6C, 0x8E, 0xFF, 0xB8))
.with_text_color(Color::rgb(0x0D, 0x14, 0x25));
#[derive(Clone, Default)]
struct SidebarCardOptions {
@@ -24,71 +36,296 @@ struct SidebarCardOptions {
max_lines: Option<usize>,
}
#[ruin_runtime::main]
fn main() -> Result<(), Box<dyn Error>> {
#[derive(Clone, Copy)]
struct InFlightResize {
scene_version: u64,
viewport: UiSize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SelectionDrag {
element_id: ElementId,
anchor: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TextSelection {
element_id: ElementId,
anchor: usize,
focus: usize,
}
impl TextSelection {
fn is_collapsed(self) -> bool {
self.anchor == self.focus
}
}
#[derive(Default)]
struct SelectionOutcome {
changed: bool,
copied_text: Option<String>,
}
fn install_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,ruin_ui::layout_perf=debug,ruin_ui_platform_wayland=trace,ruin_ui_text_paragraph_demo=trace",
)
});
let fmt_layer = fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.compact();
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.try_init();
}
#[ruin_runtime::async_main]
async fn main() -> Result<(), Box<dyn Error>> {
install_tracing();
let mut ui = start_wayland_ui();
let window = ui.create_window(
WindowSpec::new("RUIN paragraph demo")
.app_id("dev.ruin.text-paragraph-demo")
.requested_inner_size(UiSize::new(1040.0, 760.0)),
)?;
let mut viewport = UiSize::new(1040.0, 760.0);
let mut version = 1_u64;
let mut version = 0_u64;
let mut text_system = TextSystem::new();
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 base_scene: Option<SceneSnapshot> = None;
let mut interaction_tree: Option<InteractionTree> = None;
let mut pending_resize = None;
let mut in_flight_resize = None;
let mut latest_submitted_viewport = None;
let mut current_cursor = CursorIcon::Default;
let mut selection = None;
let mut selection_drag = None;
let mut window = WaylandWindow::open(
WindowSpec::new("RUIN paragraph demo")
.app_id("dev.ruin.text-paragraph-demo")
.requested_inner_size(viewport),
)?;
let mut renderer = WgpuSceneRenderer::new(
window.surface_target(),
viewport.width as u32,
viewport.height as u32,
)?;
window.request_redraw();
println!("Opening RUIN paragraph demo window...");
window.set_cursor_icon(current_cursor)?;
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;
while let Some(event) = ui.next_event().await {
let mut latest_configuration = None;
let mut resize_presented = false;
let mut pointer_events = Vec::new();
let mut close_requested = false;
let mut closed = false;
for event in std::iter::once(event).chain(ui.take_pending_events()) {
match event {
PlatformEvent::Configured {
window_id,
configuration,
} if window_id == window.id() => {
latest_configuration = Some(configuration);
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
pointer_events.push(event);
}
PlatformEvent::FramePresented {
window_id,
scene_version,
..
} if window_id == window.id() => {
resize_presented =
in_flight_resize
.as_ref()
.is_some_and(|in_flight: &InFlightResize| {
in_flight.scene_version == scene_version
});
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
close_requested = true;
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
closed = true;
}
_ => {}
}
}
if needs_scene_rebuild {
snapshot = build_snapshot(viewport, version, hovered_card, &mut text_system);
window.request_redraw();
if latest_configuration.is_some() || !pointer_events.is_empty() {
tracing::trace!(
target: "ruin_ui_text_paragraph_demo::events",
has_configured = latest_configuration.is_some(),
pointer_events = pointer_events.len(),
"processing coalesced event batch"
);
}
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);
snapshot = build_snapshot(viewport, version, hovered_card, &mut text_system);
window.request_redraw();
let has_resize_configuration = latest_configuration.is_some();
if let Some(configuration) = latest_configuration {
let next_viewport = configuration.actual_inner_size;
let is_duplicate =
pending_resize
.as_ref()
.is_some_and(|pending: &ruin_ui::WindowConfigured| {
pending.actual_inner_size == next_viewport
})
|| latest_submitted_viewport == Some(next_viewport);
if !is_duplicate {
pending_resize = Some(configuration);
}
}
match renderer.render(&snapshot.scene) {
Ok(()) => {}
Err(RenderError::Lost | RenderError::Outdated) => {
renderer.resize(frame.width, frame.height);
window.request_redraw();
if resize_presented
&& let Some(in_flight) = in_flight_resize.take()
&& pending_resize
.as_ref()
.is_some_and(|pending: &ruin_ui::WindowConfigured| {
pending.actual_inner_size == in_flight.viewport
})
{
pending_resize = None;
}
if in_flight_resize.is_none()
&& let Some(configuration) = pending_resize.take()
{
viewport = configuration.actual_inner_size;
version = version.wrapping_add(1);
let build_started = Instant::now();
tracing::debug!(
target: "ruin_ui_text_paragraph_demo::resize",
width = viewport.width,
height = viewport.height,
scene_version = version,
"rebuilding snapshot for configured size"
);
let LayoutSnapshot {
scene,
interaction_tree: next_interaction_tree,
} = build_snapshot(viewport, version, hovered_card, &mut text_system);
if selection.is_some_and(|selection: TextSelection| {
next_interaction_tree
.text_for_element(selection.element_id)
.is_none_or(|prepared_text| !prepared_text.selectable)
}) {
selection = None;
selection_drag = None;
}
tracing::debug!(
target: "ruin_ui_text_paragraph_demo::resize",
width = viewport.width,
height = viewport.height,
scene_version = version,
build_ms = build_started.elapsed().as_secs_f64() * 1_000.0,
"finished snapshot rebuild for configured size"
);
window.replace_scene(scene_with_selection(&scene, selection, version))?;
base_scene = Some(scene);
latest_submitted_viewport = Some(viewport);
in_flight_resize = Some(InFlightResize {
scene_version: version,
viewport,
});
interaction_tree = Some(next_interaction_tree);
}
let mut needs_hover_rebuild = false;
let mut needs_selection_present = false;
let mut copied_text = None::<String>;
let resize_active =
has_resize_configuration || pending_resize.is_some() || in_flight_resize.is_some();
if !resize_active && let Some(current_interaction_tree) = interaction_tree.as_ref() {
for event in pointer_events {
let selection_outcome = handle_selection_event(
current_interaction_tree,
event,
&mut selection,
&mut selection_drag,
);
needs_selection_present |= selection_outcome.changed;
if selection_outcome.copied_text.is_some() {
copied_text = selection_outcome.copied_text;
}
Err(RenderError::Timeout | RenderError::Occluded | RenderError::Validation) => {
window.request_redraw();
if selection_drag.is_none() {
let routed = pointer_router.route(current_interaction_tree, event);
let next_cursor = pointer_router
.hovered_targets()
.last()
.map(|target| target.cursor)
.unwrap_or(CursorIcon::Default);
if next_cursor != current_cursor {
current_cursor = next_cursor;
window.set_cursor_icon(current_cursor)?;
}
let next_hover = pointer_router
.hovered_targets()
.iter()
.rev()
.find_map(|target| target.element_id.filter(|id| is_hoverable_card(*id)));
if next_hover != hovered_card {
hovered_card = next_hover;
needs_hover_rebuild = true;
}
if routed.iter().any(|routed| {
routed.target.element_id == Some(RUST_LINK_ID)
&& matches!(
routed.kind,
RoutedPointerEventKind::Up {
button: PointerButton::Primary
}
)
}) {
open_rust_website();
}
}
}
}
if let Some(copied_text) = copied_text {
window.set_primary_selection_text(copied_text)?;
}
if needs_hover_rebuild && in_flight_resize.is_none() {
version = version.wrapping_add(1);
tracing::trace!(
target: "ruin_ui_text_paragraph_demo::hover",
scene_version = version,
hovered = hovered_card.map(ElementId::raw),
"rebuilding snapshot for hover change"
);
let LayoutSnapshot {
scene,
interaction_tree: next_interaction_tree,
} = build_snapshot(viewport, version, hovered_card, &mut text_system);
if selection.is_some_and(|selection: TextSelection| {
next_interaction_tree
.text_for_element(selection.element_id)
.is_none_or(|prepared_text| !prepared_text.selectable)
}) {
selection = None;
selection_drag = None;
}
window.replace_scene(scene_with_selection(&scene, selection, version))?;
base_scene = Some(scene);
interaction_tree = Some(next_interaction_tree);
} else if needs_selection_present
&& in_flight_resize.is_none()
&& let Some(base_scene) = base_scene.as_ref()
{
version = version.wrapping_add(1);
window.replace_scene(scene_with_selection(base_scene, selection, version))?;
}
if close_requested {
let _ = window.update(WindowUpdate::new().open(false));
}
if closed {
break;
}
}
let _ = ui.shutdown();
Ok(())
}
@@ -102,6 +339,133 @@ fn build_snapshot(
layout_snapshot_with_text_system(version, viewport, &tree, text_system)
}
fn handle_selection_event(
interaction_tree: &InteractionTree,
event: PointerEvent,
selection: &mut Option<TextSelection>,
selection_drag: &mut Option<SelectionDrag>,
) -> SelectionOutcome {
let mut outcome = SelectionOutcome::default();
match event.kind {
PointerEventKind::Down { .. } => {
let next_selection = interaction_tree
.text_hit_test(event.position)
.and_then(|hit| {
hit.target.element_id.map(|element_id| TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
})
});
if let Some(next_selection) = next_selection {
*selection_drag = Some(SelectionDrag {
element_id: next_selection.element_id,
anchor: next_selection.anchor,
});
outcome.changed = *selection != Some(next_selection);
*selection = Some(next_selection);
} else {
outcome.changed = selection.take().is_some();
*selection_drag = None;
}
}
PointerEventKind::Move => {
if let Some(drag) = *selection_drag
&& let Some(prepared_text) = interaction_tree.text_for_element(drag.element_id)
{
let next_selection = TextSelection {
element_id: drag.element_id,
anchor: drag.anchor,
focus: prepared_text.byte_offset_for_position(event.position),
};
outcome.changed = *selection != Some(next_selection);
*selection = Some(next_selection);
}
}
PointerEventKind::Up { .. } => {
let Some(drag) = selection_drag.take() else {
return outcome;
};
let Some(prepared_text) = interaction_tree.text_for_element(drag.element_id) else {
return outcome;
};
let next_selection = TextSelection {
element_id: drag.element_id,
anchor: drag.anchor,
focus: prepared_text.byte_offset_for_position(event.position),
};
if next_selection.is_collapsed() {
outcome.changed = selection.take().is_some();
} else {
outcome.changed = *selection != Some(next_selection);
outcome.copied_text = prepared_text
.selected_text(next_selection.anchor, next_selection.focus)
.filter(|text| !text.is_empty())
.map(str::to_owned);
*selection = Some(next_selection);
}
}
PointerEventKind::LeaveWindow => {}
}
outcome
}
fn scene_with_selection(
base_scene: &SceneSnapshot,
selection: Option<TextSelection>,
version: u64,
) -> SceneSnapshot {
let Some(selection) = selection.filter(|selection| !selection.is_collapsed()) else {
let mut scene = base_scene.clone();
scene.version = version;
return scene;
};
let mut scene = base_scene.clone();
scene.version = version;
let mut items = Vec::with_capacity(scene.items.len() + 8);
for item in scene.items.drain(..) {
if let DisplayItem::Text(prepared_text) = &item
&& prepared_text.element_id == Some(selection.element_id)
{
for rect in prepared_text.selection_rects(selection.anchor, selection.focus) {
items.push(DisplayItem::Quad(Quad::new(
rect,
prepared_text.selection_style.highlight_color,
)));
}
let mut selected_text = prepared_text.clone();
selected_text.apply_selected_text_color(selection.anchor, selection.focus);
items.push(DisplayItem::Text(selected_text));
continue;
}
items.push(item);
}
scene.items = items;
scene
}
fn demo_body_style(font_size: f32, color: Color) -> TextStyle {
TextStyle::new(font_size, color).with_selection_style(DEMO_SELECTION_STYLE)
}
fn demo_unselectable_title(text: impl Into<String>, id: ElementId, style: TextStyle) -> Element {
Element::paragraph(text, style.with_selectable(false)).id(id)
}
fn open_rust_website() {
if let Err(error) = Command::new("xdg-open")
.arg("https://www.rust-lang.org")
.spawn()
{
tracing::debug!(
target: "ruin_ui_text_paragraph_demo::link",
error = %error,
"failed to open Rust website"
);
}
}
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);
@@ -116,8 +480,9 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Ele
.gap(gutter * 0.45)
.background(Color::rgb(0x16, 0x1D, 0x2B))
.children([
Element::paragraph(
demo_unselectable_title(
"RUIN paragraph demo",
HERO_TITLE_ID,
TextStyle::new(34.0, Color::rgb(0xF5, 0xF7, 0xFB))
.with_line_height(40.0)
.with_align(TextAlign::Center),
@@ -142,10 +507,21 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Ele
" that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.",
),
],
TextStyle::new(18.0, Color::rgb(0xC9, 0xD2, 0xE3))
demo_body_style(18.0, Color::rgb(0xC9, 0xD2, 0xE3))
.with_line_height(28.0)
.with_align(TextAlign::Center),
),
)
.id(HERO_BODY_ID),
Element::rich_paragraph(
[TextSpan::new("Visit the Rust website")
.weight(TextSpanWeight::Bold)
.color(Color::rgb(0x60, 0xA5, 0xFA))],
TextStyle::new(18.0, Color::rgb(0x60, 0xA5, 0xFA))
.with_line_height(24.0)
.with_selectable(false),
)
.id(RUST_LINK_ID)
.cursor(CursorIcon::Pointer),
]),
Element::row().flex(1.0).gap(gutter).children([
Element::column()
@@ -179,7 +555,8 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Ele
card_title_color(NEXT_CARD_ID, hovered_card),
)
.with_line_height(26.0),
),
)
.id(card_text_id(NEXT_CARD_ID, 1)),
Element::rich_paragraph(
[
TextSpan::new("Next up after this slice: "),
@@ -196,9 +573,10 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Ele
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(" text editing and selection."),
],
TextStyle::new(17.0, Color::rgb(0xD8, 0xDF, 0xED))
demo_body_style(17.0, Color::rgb(0xD8, 0xDF, 0xED))
.with_line_height(26.0),
),
)
.id(card_text_id(NEXT_CARD_ID, 2)),
]),
]),
Element::column()
@@ -222,7 +600,7 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option<ElementId>) -> Ele
STATUS_CARD_ID,
hovered_card,
"Status",
"Static layout, responsive resize, paragraph wrapping, centered headings, and line clamping all share the same UI pipeline now.",
"Static layout, responsive resize, paragraph wrapping, centered headings, line clamping, and drag-to-copy text selection all share the same UI pipeline now.",
gutter,
SidebarCardOptions {
align: Some(TextAlign::End),
@@ -250,11 +628,13 @@ fn text_card(
Element::paragraph(
title,
TextStyle::new(24.0, card_title_color(id, hovered_card)).with_line_height(30.0),
),
)
.id(card_text_id(id, 1)),
Element::paragraph(
body,
TextStyle::new(18.0, Color::rgb(0xD9, 0xE0, 0xEE)).with_line_height(29.0),
),
demo_body_style(18.0, Color::rgb(0xD9, 0xE0, 0xEE)).with_line_height(29.0),
)
.id(card_text_id(id, 2)),
])
}
@@ -266,7 +646,7 @@ fn sidebar_card(
gutter: f32,
options: SidebarCardOptions,
) -> Element {
let mut body_style = TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0);
let mut body_style = demo_body_style(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0);
if let Some(align) = options.align {
body_style = body_style.with_align(align);
}
@@ -286,8 +666,9 @@ fn sidebar_card(
Element::paragraph(
title,
TextStyle::new(18.0, card_title_color(id, hovered_card)).with_line_height(24.0),
),
Element::paragraph(body, body_style),
)
.id(card_text_id(id, 1)),
Element::paragraph(body, body_style).id(card_text_id(id, 2)),
])
}
@@ -302,7 +683,8 @@ fn rich_sidebar_card(hovered_card: Option<ElementId>, gutter: f32) -> Element {
"Clamped note",
TextStyle::new(18.0, card_title_color(NOTE_CARD_ID, hovered_card))
.with_line_height(24.0),
),
)
.id(card_text_id(NOTE_CARD_ID, 1)),
Element::rich_paragraph(
[
TextSpan::new("Clamped note: the renderer now uses a "),
@@ -321,13 +703,18 @@ fn rich_sidebar_card(hovered_card: Option<ElementId>, gutter: f32) -> Element {
" layout and scene building rather than per-frame CPU text compositing.",
),
],
TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA))
demo_body_style(16.0, Color::rgb(0xD4, 0xDB, 0xEA))
.with_line_height(25.0)
.with_max_lines(4),
),
)
.id(card_text_id(NOTE_CARD_ID, 2)),
])
}
fn card_text_id(card_id: ElementId, slot: u64) -> ElementId {
ElementId::new(card_id.raw() * 10 + slot)
}
fn is_hoverable_card(id: ElementId) -> bool {
matches!(
id,