More text improvements, performance enhancements, input handling, text selection, wl cursors
This commit is contained in:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -1011,6 +1011,8 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"ruin-runtime",
|
||||
"ruin_ui",
|
||||
"ruin_ui_renderer_wgpu",
|
||||
"tracing",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -1049,7 +1051,8 @@ dependencies = [
|
||||
"ruin-runtime",
|
||||
"ruin_ui",
|
||||
"ruin_ui_platform_wayland",
|
||||
"ruin_ui_renderer_wgpu",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,7 +50,7 @@ pub struct RoutedPointerEvent {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PointerRouter {
|
||||
hovered: Option<HitTarget>,
|
||||
hovered: Vec<HitTarget>,
|
||||
pressed: Option<HitTarget>,
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ impl PointerRouter {
|
||||
}
|
||||
|
||||
pub fn hovered_target(&self) -> Option<&HitTarget> {
|
||||
self.hovered.as_ref()
|
||||
self.hovered.last()
|
||||
}
|
||||
|
||||
pub fn hovered_targets(&self) -> &[HitTarget] {
|
||||
&self.hovered
|
||||
}
|
||||
|
||||
pub fn pressed_target(&self) -> Option<&HitTarget> {
|
||||
@@ -72,14 +76,21 @@ impl PointerRouter {
|
||||
interaction_tree: &InteractionTree,
|
||||
event: PointerEvent,
|
||||
) -> Vec<RoutedPointerEvent> {
|
||||
let hit_target = match event.kind {
|
||||
PointerEventKind::LeaveWindow => None,
|
||||
_ => interaction_tree.hit_test(event.position),
|
||||
let hovered_targets = match event.kind {
|
||||
PointerEventKind::LeaveWindow => Vec::new(),
|
||||
_ => interaction_tree.hit_path(event.position),
|
||||
};
|
||||
let hit_target = hovered_targets.last().cloned();
|
||||
|
||||
let mut routed = Vec::new();
|
||||
if self.hovered != hit_target {
|
||||
if let Some(previous) = self.hovered.take() {
|
||||
let shared_depth = self
|
||||
.hovered
|
||||
.iter()
|
||||
.zip(hovered_targets.iter())
|
||||
.take_while(|(previous, next)| previous == next)
|
||||
.count();
|
||||
if shared_depth != self.hovered.len() || shared_depth != hovered_targets.len() {
|
||||
for previous in self.hovered[shared_depth..].iter().rev().cloned() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Leave,
|
||||
target: previous,
|
||||
@@ -87,20 +98,20 @@ impl PointerRouter {
|
||||
position: event.position,
|
||||
});
|
||||
}
|
||||
if let Some(target) = hit_target.clone() {
|
||||
for target in hovered_targets[shared_depth..].iter().cloned() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Enter,
|
||||
target: target.clone(),
|
||||
target,
|
||||
pointer_id: event.pointer_id,
|
||||
position: event.position,
|
||||
});
|
||||
self.hovered = Some(target);
|
||||
}
|
||||
self.hovered = hovered_targets;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
PointerEventKind::Move => {
|
||||
if let Some(target) = hit_target {
|
||||
if let Some(target) = hit_target.clone() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Move,
|
||||
target,
|
||||
@@ -144,7 +155,7 @@ mod tests {
|
||||
};
|
||||
use crate::layout::{InteractionTree, LayoutNode, LayoutPath};
|
||||
use crate::scene::{Point, Rect};
|
||||
use crate::tree::ElementId;
|
||||
use crate::tree::{CursorIcon, ElementId};
|
||||
|
||||
fn interaction_tree() -> InteractionTree {
|
||||
InteractionTree {
|
||||
@@ -153,12 +164,16 @@ mod tests {
|
||||
element_id: None,
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
|
||||
pointer_events: false,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
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,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
LayoutNode {
|
||||
@@ -166,6 +181,8 @@ mod tests {
|
||||
element_id: Some(ElementId::new(2)),
|
||||
rect: Rect::new(80.0, 0.0, 120.0, 120.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
@@ -173,6 +190,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn nested_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,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: vec![LayoutNode {
|
||||
path: LayoutPath::root().child(0),
|
||||
element_id: Some(ElementId::new(1)),
|
||||
rect: Rect::new(0.0, 0.0, 160.0, 120.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: vec![LayoutNode {
|
||||
path: LayoutPath::root().child(0).child(0),
|
||||
element_id: Some(ElementId::new(2)),
|
||||
rect: Rect::new(16.0, 16.0, 80.0, 40.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_emits_enter_and_move_for_new_hover_target() {
|
||||
let mut router = PointerRouter::new();
|
||||
@@ -223,4 +270,66 @@ mod tests {
|
||||
Some(ElementId::new(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_tracks_hovered_ancestors_for_nested_hits() {
|
||||
let mut router = PointerRouter::new();
|
||||
let routed = router.route(
|
||||
&nested_interaction_tree(),
|
||||
PointerEvent::new(0, Point::new(24.0, 24.0), PointerEventKind::Move),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
routed
|
||||
.iter()
|
||||
.filter(|event| event.kind == RoutedPointerEventKind::Enter)
|
||||
.map(|event| event.target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1)), Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.hovered_targets()
|
||||
.iter()
|
||||
.map(|target| target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1)), Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router.hovered_target().unwrap().element_id,
|
||||
Some(ElementId::new(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_leaves_only_nested_child_when_pointer_moves_to_parent() {
|
||||
let mut router = PointerRouter::new();
|
||||
let tree = nested_interaction_tree();
|
||||
let _ = router.route(
|
||||
&tree,
|
||||
PointerEvent::new(0, Point::new(24.0, 24.0), PointerEventKind::Move),
|
||||
);
|
||||
|
||||
let routed = router.route(
|
||||
&tree,
|
||||
PointerEvent::new(0, Point::new(140.0, 24.0), PointerEventKind::Move),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
routed
|
||||
.iter()
|
||||
.filter(|event| event.kind == RoutedPointerEventKind::Leave)
|
||||
.map(|event| event.target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.hovered_targets()
|
||||
.iter()
|
||||
.map(|target| target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::scene::{Rect, SceneSnapshot, UiSize};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::scene::{PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use crate::text::TextSystem;
|
||||
use crate::tree::{Edges, Element, ElementId, FlexDirection};
|
||||
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection};
|
||||
|
||||
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
||||
let mut text_system = TextSystem::new();
|
||||
@@ -46,6 +48,7 @@ pub struct HitTarget {
|
||||
pub path: LayoutPath,
|
||||
pub element_id: Option<ElementId>,
|
||||
pub rect: Rect,
|
||||
pub cursor: CursorIcon,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -54,9 +57,17 @@ pub struct LayoutNode {
|
||||
pub element_id: Option<ElementId>,
|
||||
pub rect: Rect,
|
||||
pub pointer_events: bool,
|
||||
pub cursor: CursorIcon,
|
||||
pub prepared_text: Option<PreparedText>,
|
||||
pub children: Vec<LayoutNode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextHitTarget {
|
||||
pub target: HitTarget,
|
||||
pub byte_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InteractionTree {
|
||||
pub root: LayoutNode,
|
||||
@@ -64,7 +75,23 @@ pub struct InteractionTree {
|
||||
|
||||
impl InteractionTree {
|
||||
pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
|
||||
hit_test_node(&self.root, point)
|
||||
self.hit_path(point).into_iter().last()
|
||||
}
|
||||
|
||||
pub fn hit_path(&self, point: crate::scene::Point) -> Vec<HitTarget> {
|
||||
let Some(mut path) = hit_path_node(&self.root, point) else {
|
||||
return Vec::new();
|
||||
};
|
||||
path.reverse();
|
||||
path
|
||||
}
|
||||
|
||||
pub fn text_hit_test(&self, point: crate::scene::Point) -> Option<TextHitTarget> {
|
||||
text_hit_test_node(&self.root, point)
|
||||
}
|
||||
|
||||
pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> {
|
||||
text_for_element_node(&self.root, element_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +106,10 @@ pub fn layout_snapshot_with_text_system(
|
||||
root: &Element,
|
||||
text_system: &mut TextSystem,
|
||||
) -> LayoutSnapshot {
|
||||
let layout_started = Instant::now();
|
||||
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
|
||||
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
|
||||
text_system.reset_frame_stats();
|
||||
let mut scene = SceneSnapshot::new(version, logical_size);
|
||||
let interaction_root = layout_element(
|
||||
root,
|
||||
@@ -91,7 +122,39 @@ pub fn layout_snapshot_with_text_system(
|
||||
LayoutPath::root(),
|
||||
&mut scene,
|
||||
text_system,
|
||||
&mut perf_stats,
|
||||
);
|
||||
let text_stats = text_system.take_frame_stats();
|
||||
if perf_stats.enabled {
|
||||
tracing::debug!(
|
||||
target: "ruin_ui::layout_perf",
|
||||
scene_version = version,
|
||||
width = logical_size.width,
|
||||
height = logical_size.height,
|
||||
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0,
|
||||
nodes = perf_stats.nodes,
|
||||
text_nodes = perf_stats.text_nodes,
|
||||
container_nodes = perf_stats.container_nodes,
|
||||
background_quads = perf_stats.background_quads,
|
||||
intrinsic_calls = perf_stats.intrinsic_calls,
|
||||
intrinsic_size_calls = perf_stats.intrinsic_size_calls,
|
||||
intrinsic_text_calls = perf_stats.intrinsic_text_calls,
|
||||
intrinsic_container_calls = perf_stats.intrinsic_container_calls,
|
||||
intrinsic_ms = perf_stats.intrinsic_ms,
|
||||
text_prepare_calls = perf_stats.text_prepare_calls,
|
||||
text_prepare_ms = perf_stats.text_prepare_ms,
|
||||
text_requests = text_stats.requests,
|
||||
text_cache_hits = text_stats.cache_hits,
|
||||
text_cache_misses = text_stats.cache_misses,
|
||||
text_output_glyphs = text_stats.output_glyphs,
|
||||
text_family_resolve_ms = text_stats.family_resolve_ms,
|
||||
text_buffer_build_ms = text_stats.buffer_build_ms,
|
||||
text_glyph_collect_ms = text_stats.glyph_collect_ms,
|
||||
text_miss_ms = text_stats.miss_ms,
|
||||
scene_items = scene.items.len(),
|
||||
"layout snapshot perf"
|
||||
);
|
||||
}
|
||||
LayoutSnapshot {
|
||||
scene,
|
||||
interaction_tree: InteractionTree {
|
||||
@@ -106,12 +169,23 @@ fn layout_element(
|
||||
path: LayoutPath,
|
||||
scene: &mut SceneSnapshot,
|
||||
text_system: &mut TextSystem,
|
||||
perf_stats: &mut LayoutPerfStats,
|
||||
) -> LayoutNode {
|
||||
perf_stats.nodes += 1;
|
||||
let cursor = element.style.cursor.unwrap_or_else(|| {
|
||||
if element.text_node().is_some() {
|
||||
CursorIcon::Text
|
||||
} else {
|
||||
CursorIcon::Default
|
||||
}
|
||||
});
|
||||
let mut interaction = LayoutNode {
|
||||
path,
|
||||
element_id: element.id,
|
||||
rect,
|
||||
pointer_events: element.style.pointer_events,
|
||||
cursor,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -120,21 +194,34 @@ fn layout_element(
|
||||
}
|
||||
|
||||
if let Some(color) = element.style.background {
|
||||
perf_stats.background_quads += 1;
|
||||
scene.push_quad(rect, color);
|
||||
}
|
||||
|
||||
if let Some(text) = element.text_node() {
|
||||
perf_stats.text_nodes += 1;
|
||||
let content = inset_rect(rect, element.style.padding);
|
||||
if content.size.width > 0.0 && content.size.height > 0.0 {
|
||||
scene.push_text(text_system.prepare_spans(
|
||||
text.spans.clone(),
|
||||
perf_stats.text_prepare_calls += 1;
|
||||
let prepare_started = perf_stats.enabled.then(Instant::now);
|
||||
let mut prepared = text_system.prepare_spans(
|
||||
&text.spans,
|
||||
content.origin,
|
||||
text.style.clone().with_bounds(content.size),
|
||||
));
|
||||
&text.style,
|
||||
Some(content.size),
|
||||
);
|
||||
prepared.element_id = element.id;
|
||||
scene.push_text(prepared.clone());
|
||||
interaction.prepared_text = Some(prepared);
|
||||
if let Some(prepare_started) = prepare_started {
|
||||
perf_stats.text_prepare_ms += prepare_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
}
|
||||
}
|
||||
return interaction;
|
||||
}
|
||||
|
||||
perf_stats.container_nodes += 1;
|
||||
|
||||
if element.children.is_empty() {
|
||||
return interaction;
|
||||
}
|
||||
@@ -164,13 +251,25 @@ fn layout_element(
|
||||
if is_flex {
|
||||
0.0
|
||||
} else {
|
||||
intrinsic_main_size(
|
||||
perf_stats.intrinsic_calls += 1;
|
||||
if child.text_node().is_some() {
|
||||
perf_stats.intrinsic_text_calls += 1;
|
||||
} else {
|
||||
perf_stats.intrinsic_container_calls += 1;
|
||||
}
|
||||
let intrinsic_started = perf_stats.enabled.then(Instant::now);
|
||||
let intrinsic = intrinsic_main_size(
|
||||
child,
|
||||
element.style.direction,
|
||||
cross,
|
||||
available_main,
|
||||
text_system,
|
||||
)
|
||||
perf_stats,
|
||||
);
|
||||
if let Some(intrinsic_started) = intrinsic_started {
|
||||
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
}
|
||||
intrinsic
|
||||
}
|
||||
});
|
||||
if is_flex {
|
||||
@@ -211,6 +310,7 @@ fn layout_element(
|
||||
interaction.path.child(index),
|
||||
scene,
|
||||
text_system,
|
||||
perf_stats,
|
||||
));
|
||||
cursor += child_main.max(0.0) + element.style.gap;
|
||||
}
|
||||
@@ -218,23 +318,78 @@ fn layout_element(
|
||||
interaction
|
||||
}
|
||||
|
||||
fn hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<HitTarget> {
|
||||
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<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 let Some(mut hits) = hit_path_node(child, point) {
|
||||
if node.pointer_events {
|
||||
hits.push(HitTarget {
|
||||
path: node.path.clone(),
|
||||
element_id: node.element_id,
|
||||
rect: node.rect,
|
||||
cursor: node.cursor,
|
||||
});
|
||||
}
|
||||
return Some(hits);
|
||||
}
|
||||
}
|
||||
|
||||
if node.pointer_events {
|
||||
return Some(HitTarget {
|
||||
return Some(vec![HitTarget {
|
||||
path: node.path.clone(),
|
||||
element_id: node.element_id,
|
||||
rect: node.rect,
|
||||
});
|
||||
cursor: node.cursor,
|
||||
}]);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<TextHitTarget> {
|
||||
if !node.rect.contains(point) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for child in node.children.iter().rev() {
|
||||
if let Some(hit) = text_hit_test_node(child, point) {
|
||||
return Some(hit);
|
||||
}
|
||||
}
|
||||
|
||||
if !node.pointer_events {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prepared_text = node.prepared_text.as_ref()?;
|
||||
if !prepared_text.selectable {
|
||||
return None;
|
||||
}
|
||||
Some(TextHitTarget {
|
||||
target: HitTarget {
|
||||
path: node.path.clone(),
|
||||
element_id: node.element_id,
|
||||
rect: node.rect,
|
||||
cursor: node.cursor,
|
||||
},
|
||||
byte_offset: prepared_text.byte_offset_for_position(point),
|
||||
})
|
||||
}
|
||||
|
||||
fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&PreparedText> {
|
||||
if node.element_id == Some(element_id)
|
||||
&& let Some(prepared_text) = node.prepared_text.as_ref()
|
||||
{
|
||||
return Some(prepared_text);
|
||||
}
|
||||
|
||||
for child in &node.children {
|
||||
if let Some(prepared_text) = text_for_element_node(child, element_id) {
|
||||
return Some(prepared_text);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
@@ -253,18 +408,15 @@ fn intrinsic_main_size(
|
||||
cross_size: f32,
|
||||
available_main: f32,
|
||||
text_system: &mut TextSystem,
|
||||
perf_stats: &mut LayoutPerfStats,
|
||||
) -> f32 {
|
||||
if let Some(text) = child.text_node() {
|
||||
let constraints = match direction {
|
||||
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
|
||||
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
|
||||
};
|
||||
let content = text_system.measure_spans(
|
||||
text.spans.clone(),
|
||||
text.style.clone(),
|
||||
constraints.0,
|
||||
constraints.1,
|
||||
);
|
||||
let content =
|
||||
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
||||
let padding = main_axis_padding(child.style.padding, direction);
|
||||
return main_axis_size(content, direction) + padding;
|
||||
}
|
||||
@@ -274,7 +426,7 @@ fn intrinsic_main_size(
|
||||
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
|
||||
};
|
||||
main_axis_size(
|
||||
intrinsic_size(child, available_size, text_system),
|
||||
intrinsic_size(child, available_size, text_system, perf_stats),
|
||||
direction,
|
||||
)
|
||||
}
|
||||
@@ -283,11 +435,13 @@ fn intrinsic_size(
|
||||
element: &Element,
|
||||
available_size: UiSize,
|
||||
text_system: &mut TextSystem,
|
||||
perf_stats: &mut LayoutPerfStats,
|
||||
) -> UiSize {
|
||||
perf_stats.intrinsic_size_calls += 1;
|
||||
if let Some(text) = element.text_node() {
|
||||
let measured = text_system.measure_spans(
|
||||
text.spans.clone(),
|
||||
text.style.clone(),
|
||||
&text.spans,
|
||||
&text.style,
|
||||
Some(available_size.width.max(0.0)),
|
||||
Some(available_size.height.max(0.0)),
|
||||
);
|
||||
@@ -334,6 +488,7 @@ fn intrinsic_size(
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
text_system,
|
||||
perf_stats,
|
||||
);
|
||||
width = width.max(child.style.width.unwrap_or(child_size.width));
|
||||
height += child.style.height.unwrap_or(child_size.height);
|
||||
@@ -354,6 +509,7 @@ fn intrinsic_size(
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
text_system,
|
||||
perf_stats,
|
||||
);
|
||||
width += child.style.width.unwrap_or(child_size.width);
|
||||
height = height.max(child.style.height.unwrap_or(child_size.height));
|
||||
@@ -372,6 +528,41 @@ fn intrinsic_size(
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LayoutPerfStats {
|
||||
enabled: bool,
|
||||
nodes: usize,
|
||||
text_nodes: usize,
|
||||
container_nodes: usize,
|
||||
background_quads: usize,
|
||||
intrinsic_calls: usize,
|
||||
intrinsic_size_calls: usize,
|
||||
intrinsic_text_calls: usize,
|
||||
intrinsic_container_calls: usize,
|
||||
intrinsic_ms: f64,
|
||||
text_prepare_calls: usize,
|
||||
text_prepare_ms: f64,
|
||||
}
|
||||
|
||||
impl LayoutPerfStats {
|
||||
const fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
nodes: 0,
|
||||
text_nodes: 0,
|
||||
container_nodes: 0,
|
||||
background_quads: 0,
|
||||
intrinsic_calls: 0,
|
||||
intrinsic_size_calls: 0,
|
||||
intrinsic_text_calls: 0,
|
||||
intrinsic_container_calls: 0,
|
||||
intrinsic_ms: 0.0,
|
||||
text_prepare_calls: 0,
|
||||
text_prepare_ms: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inset_rect(rect: Rect, edges: Edges) -> Rect {
|
||||
let width = (rect.size.width - edges.left - edges.right).max(0.0);
|
||||
let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
|
||||
@@ -625,4 +816,84 @@ mod tests {
|
||||
.expect("point should still hit parent");
|
||||
assert_eq!(hit.element_id, Some(ElementId::new(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_tree_hit_path_includes_pointer_ancestors() {
|
||||
let root = Element::column().pointer_events(false).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)
|
||||
.background(Color::rgb(0x44, 0x55, 0x66)),
|
||||
),
|
||||
);
|
||||
|
||||
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
|
||||
let hit_path = snapshot.interaction_tree.hit_path(Point::new(20.0, 20.0));
|
||||
let hit_ids: Vec<Option<ElementId>> =
|
||||
hit_path.iter().map(|target| target.element_id).collect();
|
||||
assert_eq!(
|
||||
hit_ids,
|
||||
vec![Some(ElementId::new(2)), Some(ElementId::new(3))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_tree_exposes_prepared_text_for_text_nodes() {
|
||||
let text_id = ElementId::new(9);
|
||||
let root = Element::column().child(
|
||||
Element::paragraph(
|
||||
"Selection should be able to map pointer positions back into prepared text.",
|
||||
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
||||
)
|
||||
.id(text_id),
|
||||
);
|
||||
|
||||
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
|
||||
let prepared_text = snapshot
|
||||
.interaction_tree
|
||||
.text_for_element(text_id)
|
||||
.expect("text node should expose prepared text");
|
||||
let text_hit = snapshot
|
||||
.interaction_tree
|
||||
.text_hit_test(Point::new(12.0, 12.0))
|
||||
.expect("point should hit prepared text");
|
||||
|
||||
assert_eq!(prepared_text.element_id, Some(text_id));
|
||||
assert_eq!(text_hit.target.element_id, Some(text_id));
|
||||
assert!(text_hit.byte_offset <= prepared_text.text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_tree_skips_unselectable_text_nodes() {
|
||||
let text_id = ElementId::new(10);
|
||||
let root = Element::column().child(
|
||||
Element::paragraph(
|
||||
"Titles and labels can opt out of text selection.",
|
||||
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_line_height(24.0)
|
||||
.with_selectable(false),
|
||||
)
|
||||
.id(text_id),
|
||||
);
|
||||
|
||||
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
|
||||
assert!(
|
||||
snapshot
|
||||
.interaction_tree
|
||||
.text_for_element(text_id)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
snapshot
|
||||
.interaction_tree
|
||||
.text_hit_test(Point::new(12.0, 12.0))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,21 +24,24 @@ pub use interaction::{
|
||||
RoutedPointerEventKind,
|
||||
};
|
||||
pub use layout::{
|
||||
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, layout_snapshot,
|
||||
layout_snapshot_with_text_system,
|
||||
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, TextHitTarget,
|
||||
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 platform::{
|
||||
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
|
||||
PlatformRuntime, start_headless,
|
||||
};
|
||||
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
|
||||
pub use scene::{
|
||||
Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
|
||||
UiSize,
|
||||
Color, DisplayItem, GlyphInstance, Point, PreparedText, PreparedTextLine, Quad, Rect,
|
||||
SceneSnapshot, Translation, UiSize,
|
||||
};
|
||||
pub use text::{
|
||||
TextAlign, TextFontFamily, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem,
|
||||
TextWrap,
|
||||
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,
|
||||
TextStyle, TextSystem, TextWrap,
|
||||
};
|
||||
pub use tree::{Edges, Element, ElementId, FlexDirection, Style};
|
||||
pub use tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, Style};
|
||||
pub use window::{
|
||||
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||
};
|
||||
|
||||
@@ -14,11 +14,12 @@ use tracing::{debug, info};
|
||||
use crate::interaction::PointerEvent;
|
||||
use crate::scene::{SceneSnapshot, UiSize};
|
||||
use crate::trace_targets;
|
||||
use crate::tree::CursorIcon;
|
||||
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlatformProxy {
|
||||
command_tx: mpsc::UnboundedSender<PlatformCommand>,
|
||||
command_tx: mpsc::UnboundedSender<PlatformRequest>,
|
||||
next_window_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
@@ -33,6 +34,11 @@ pub struct PlatformRuntime {
|
||||
_worker: WorkerHandle,
|
||||
}
|
||||
|
||||
pub struct PlatformEndpoint {
|
||||
pub commands: mpsc::Receiver<PlatformRequest>,
|
||||
pub events: mpsc::UnboundedSender<PlatformEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
Opened {
|
||||
@@ -75,7 +81,7 @@ impl fmt::Display for PlatformClosed {
|
||||
impl std::error::Error for PlatformClosed {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PlatformCommand {
|
||||
pub enum PlatformRequest {
|
||||
CreateWindow {
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
@@ -88,9 +94,21 @@ enum PlatformCommand {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
},
|
||||
SetPrimarySelectionText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
},
|
||||
SetCursorIcon {
|
||||
window_id: WindowId,
|
||||
cursor: CursorIcon,
|
||||
},
|
||||
EmitCloseRequested {
|
||||
window_id: WindowId,
|
||||
},
|
||||
EmitPointerEvent {
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -135,6 +153,25 @@ impl PlatformRuntime {
|
||||
start_headless()
|
||||
}
|
||||
|
||||
pub fn custom(start: impl FnOnce(PlatformEndpoint) -> WorkerHandle) -> Self {
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel::<PlatformRequest>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
||||
let proxy = PlatformProxy {
|
||||
command_tx,
|
||||
next_window_id: Arc::new(AtomicU64::new(1)),
|
||||
};
|
||||
let worker = start(PlatformEndpoint {
|
||||
commands: command_rx,
|
||||
events: event_tx,
|
||||
});
|
||||
|
||||
Self {
|
||||
proxy,
|
||||
events: event_rx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy(&self) -> PlatformProxy {
|
||||
self.proxy.clone()
|
||||
}
|
||||
@@ -142,12 +179,20 @@ impl PlatformRuntime {
|
||||
pub async fn next_event(&mut self) -> Option<PlatformEvent> {
|
||||
self.events.recv().await
|
||||
}
|
||||
|
||||
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformProxy {
|
||||
pub fn create_window(&self, spec: WindowSpec) -> Result<WindowId, PlatformClosed> {
|
||||
let window_id = WindowId::from_raw(self.next_window_id.fetch_add(1, Ordering::Relaxed));
|
||||
self.send(PlatformCommand::CreateWindow { window_id, spec })?;
|
||||
self.send(PlatformRequest::CreateWindow { window_id, spec })?;
|
||||
Ok(window_id)
|
||||
}
|
||||
|
||||
@@ -156,7 +201,7 @@ impl PlatformProxy {
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::UpdateWindow { window_id, update })
|
||||
self.send(PlatformRequest::UpdateWindow { window_id, update })
|
||||
}
|
||||
|
||||
pub fn replace_scene(
|
||||
@@ -164,18 +209,45 @@ impl PlatformProxy {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::ReplaceScene { window_id, scene })
|
||||
self.send(PlatformRequest::ReplaceScene { window_id, scene })
|
||||
}
|
||||
|
||||
pub fn set_primary_selection_text(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::SetPrimarySelectionText {
|
||||
window_id,
|
||||
text: text.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_cursor_icon(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
cursor: CursorIcon,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::SetCursorIcon { window_id, cursor })
|
||||
}
|
||||
|
||||
pub fn emit_close_requested(&self, window_id: WindowId) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::EmitCloseRequested { window_id })
|
||||
self.send(PlatformRequest::EmitCloseRequested { window_id })
|
||||
}
|
||||
|
||||
pub fn emit_pointer_event(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::EmitPointerEvent { window_id, event })
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::Shutdown)
|
||||
self.send(PlatformRequest::Shutdown)
|
||||
}
|
||||
|
||||
fn send(&self, command: PlatformCommand) -> Result<(), PlatformClosed> {
|
||||
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> {
|
||||
self.command_tx.send(command).map_err(|_| PlatformClosed)
|
||||
}
|
||||
}
|
||||
@@ -186,69 +258,64 @@ impl PlatformProxy {
|
||||
/// scene for that window even if the scene version itself did not change. Visibility restoration is
|
||||
/// treated as a presentation-affecting state change.
|
||||
pub fn start_headless() -> PlatformRuntime {
|
||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<PlatformCommand>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
||||
PlatformRuntime::custom(|mut endpoint| {
|
||||
spawn_worker(
|
||||
move || {
|
||||
let state = Rc::new(RefCell::new(HeadlessState {
|
||||
events: endpoint.events.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
|
||||
let worker = spawn_worker(
|
||||
move || {
|
||||
let state = Rc::new(RefCell::new(HeadlessState {
|
||||
events: event_tx.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
queue_future(async move {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_started",
|
||||
backend = "headless",
|
||||
"starting headless platform worker"
|
||||
);
|
||||
|
||||
queue_future(async move {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_started",
|
||||
backend = "headless",
|
||||
"starting headless platform worker"
|
||||
);
|
||||
|
||||
while let Some(command) = command_rx.recv().await {
|
||||
match command {
|
||||
PlatformCommand::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, window_id, spec);
|
||||
}
|
||||
PlatformCommand::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, window_id, update);
|
||||
}
|
||||
PlatformCommand::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformCommand::EmitCloseRequested { window_id } => {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformCommand::Shutdown => {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_shutdown_requested",
|
||||
"shutting down headless platform worker"
|
||||
);
|
||||
break;
|
||||
while let Some(command) = endpoint.commands.recv().await {
|
||||
match command {
|
||||
PlatformRequest::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, window_id, spec);
|
||||
}
|
||||
PlatformRequest::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, window_id, update);
|
||||
}
|
||||
PlatformRequest::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformRequest::SetPrimarySelectionText { .. } => {}
|
||||
PlatformRequest::SetCursorIcon { .. } => {}
|
||||
PlatformRequest::EmitCloseRequested { window_id } => {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformRequest::EmitPointerEvent { window_id, event } => {
|
||||
handle_emit_pointer_event(&state, window_id, event);
|
||||
}
|
||||
PlatformRequest::Shutdown => {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_shutdown_requested",
|
||||
"shutting down headless platform worker"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_exited",
|
||||
backend = "headless",
|
||||
"headless platform worker exited"
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlatformRuntime {
|
||||
proxy: PlatformProxy {
|
||||
command_tx,
|
||||
next_window_id: Arc::new(AtomicU64::new(1)),
|
||||
},
|
||||
events: event_rx,
|
||||
_worker: worker,
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_exited",
|
||||
backend = "headless",
|
||||
"headless platform worker exited"
|
||||
);
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_create_window(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, spec: WindowSpec) {
|
||||
@@ -450,6 +517,20 @@ fn set_visibility(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, visib
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_emit_pointer_event(
|
||||
state: &Rc<RefCell<HeadlessState>>,
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
) {
|
||||
let should_emit = matches!(
|
||||
state.borrow().windows.get(&window_id),
|
||||
Some(window) if window.lifecycle == WindowLifecycle::OpenVisible
|
||||
);
|
||||
if should_emit {
|
||||
emit_event(state, PlatformEvent::Pointer { window_id, event });
|
||||
}
|
||||
}
|
||||
|
||||
fn present_latest_scene(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
let presentation = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
|
||||
@@ -4,6 +4,7 @@ use ruin_reactivity::{EffectHandle, effect};
|
||||
|
||||
use crate::platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime};
|
||||
use crate::scene::SceneSnapshot;
|
||||
use crate::tree::CursorIcon;
|
||||
use crate::window::{WindowId, WindowSpec, WindowUpdate};
|
||||
|
||||
/// High-level UI-side owner of platform event consumption.
|
||||
@@ -39,11 +40,13 @@ impl std::fmt::Display for EventStreamClosed {
|
||||
impl std::error::Error for EventStreamClosed {}
|
||||
|
||||
impl UiRuntime {
|
||||
pub fn from_platform(platform: PlatformRuntime) -> Self {
|
||||
Self { platform }
|
||||
}
|
||||
|
||||
/// Creates a UI runtime backed by the headless prototype backend.
|
||||
pub fn headless() -> Self {
|
||||
Self {
|
||||
platform: PlatformRuntime::headless(),
|
||||
}
|
||||
Self::from_platform(PlatformRuntime::headless())
|
||||
}
|
||||
|
||||
/// Returns a cloneable proxy for low-level platform commands.
|
||||
@@ -65,6 +68,11 @@ impl UiRuntime {
|
||||
self.platform.next_event().await
|
||||
}
|
||||
|
||||
/// Drains any platform events that are already queued locally.
|
||||
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
|
||||
self.platform.take_pending_events()
|
||||
}
|
||||
|
||||
/// Waits until an event matches `predicate`.
|
||||
pub async fn wait_for_event_matching(
|
||||
&mut self,
|
||||
@@ -100,11 +108,32 @@ impl WindowController {
|
||||
self.proxy.replace_scene(self.id, scene)
|
||||
}
|
||||
|
||||
/// Copies plain text to the platform primary-selection buffer for this window.
|
||||
pub fn set_primary_selection_text(
|
||||
&self,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.proxy.set_primary_selection_text(self.id, text)
|
||||
}
|
||||
|
||||
/// Updates the platform cursor icon for this window.
|
||||
pub fn set_cursor_icon(&self, cursor: CursorIcon) -> Result<(), PlatformClosed> {
|
||||
self.proxy.set_cursor_icon(self.id, cursor)
|
||||
}
|
||||
|
||||
/// Emits a close-request event for this window.
|
||||
pub fn emit_close_requested(&self) -> Result<(), PlatformClosed> {
|
||||
self.proxy.emit_close_requested(self.id)
|
||||
}
|
||||
|
||||
/// Delivers a pointer event for this window through the platform event stream.
|
||||
pub fn emit_pointer_event(
|
||||
&self,
|
||||
event: crate::interaction::PointerEvent,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.proxy.emit_pointer_event(self.id, event)
|
||||
}
|
||||
|
||||
/// Attaches a reactive effect that rebuilds and replaces the window scene whenever dependent UI
|
||||
/// state changes.
|
||||
pub fn attach_scene_effect(
|
||||
@@ -127,6 +156,7 @@ mod tests {
|
||||
use crate::platform::PlatformEvent;
|
||||
use crate::scene::{Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use crate::window::{WindowSpec, WindowUpdate};
|
||||
use crate::{PointerEvent, PointerEventKind};
|
||||
use ruin_runtime::{current_thread_handle, queue_future, run};
|
||||
use std::future::Future;
|
||||
|
||||
@@ -235,4 +265,49 @@ mod tests {
|
||||
ui.shutdown().expect("shutdown should queue");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_controller_emits_pointer_events_through_runtime() {
|
||||
run_async_test(async move {
|
||||
let mut ui = UiRuntime::headless();
|
||||
let window = ui
|
||||
.create_window(WindowSpec::new("pointer-events").visible(true))
|
||||
.expect("window should be created");
|
||||
|
||||
let _ = ui
|
||||
.wait_for_event_matching(|event| {
|
||||
matches!(event, PlatformEvent::Opened { window_id } if *window_id == window.id())
|
||||
})
|
||||
.await
|
||||
.expect("window should open before pointer delivery");
|
||||
|
||||
window
|
||||
.emit_pointer_event(PointerEvent::new(
|
||||
0,
|
||||
Point::new(24.0, 32.0),
|
||||
PointerEventKind::Move,
|
||||
))
|
||||
.expect("pointer event should queue");
|
||||
|
||||
let event = ui
|
||||
.wait_for_event_matching(|event| {
|
||||
matches!(
|
||||
event,
|
||||
PlatformEvent::Pointer { window_id, .. } if *window_id == window.id()
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("pointer event should be delivered");
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
PlatformEvent::Pointer {
|
||||
window_id: window.id(),
|
||||
event: PointerEvent::new(0, Point::new(24.0, 32.0), PointerEventKind::Move),
|
||||
}
|
||||
);
|
||||
|
||||
ui.shutdown().expect("shutdown should queue");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Renderer-oriented scene snapshot types.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use cosmic_text::CacheKey;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::text::TextSelectionStyle;
|
||||
use crate::trace_targets;
|
||||
use crate::tree::ElementId;
|
||||
|
||||
pub type SceneVersion = u64;
|
||||
|
||||
@@ -53,7 +57,7 @@ impl Rect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
@@ -97,21 +101,35 @@ impl Quad {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlyphInstance {
|
||||
pub glyph: String,
|
||||
pub position: Point,
|
||||
pub advance: f32,
|
||||
pub color: Color,
|
||||
pub cache_key: Option<CacheKey>,
|
||||
pub text_start: usize,
|
||||
pub text_end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedTextLine {
|
||||
pub rect: Rect,
|
||||
pub text_start: usize,
|
||||
pub text_end: usize,
|
||||
pub glyph_start: usize,
|
||||
pub glyph_end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedText {
|
||||
pub element_id: Option<ElementId>,
|
||||
pub text: String,
|
||||
pub origin: Point,
|
||||
pub bounds: Option<UiSize>,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub color: Color,
|
||||
pub selectable: bool,
|
||||
pub selection_style: TextSelectionStyle,
|
||||
pub lines: Vec<PreparedTextLine>,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
}
|
||||
|
||||
@@ -126,27 +144,147 @@ impl PreparedText {
|
||||
let text = text.into();
|
||||
let mut x = origin.x;
|
||||
let mut glyphs = Vec::with_capacity(text.chars().count());
|
||||
for ch in text.chars() {
|
||||
for (text_start, ch) in text.char_indices() {
|
||||
let text_end = text_start + ch.len_utf8();
|
||||
glyphs.push(GlyphInstance {
|
||||
glyph: ch.to_string(),
|
||||
position: Point::new(x, origin.y),
|
||||
advance,
|
||||
color,
|
||||
cache_key: None,
|
||||
text_start,
|
||||
text_end,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
Self {
|
||||
element_id: None,
|
||||
text,
|
||||
origin,
|
||||
bounds: None,
|
||||
font_size,
|
||||
line_height: font_size,
|
||||
color,
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
lines: vec![PreparedTextLine {
|
||||
rect: Rect::new(origin.x, origin.y, x - origin.x, font_size),
|
||||
text_start: 0,
|
||||
text_end: glyphs.last().map_or(0, |glyph| glyph.text_end),
|
||||
glyph_start: 0,
|
||||
glyph_end: glyphs.len(),
|
||||
}],
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn byte_offset_for_position(&self, point: Point) -> usize {
|
||||
let Some(line) = self.line_for_position(point.y) else {
|
||||
return 0;
|
||||
};
|
||||
self.byte_offset_for_line_position(line, point.x)
|
||||
}
|
||||
|
||||
pub fn selection_range(&self, start: usize, end: usize) -> Range<usize> {
|
||||
let start = start.min(self.text.len());
|
||||
let end = end.min(self.text.len());
|
||||
if start <= end { start..end } else { end..start }
|
||||
}
|
||||
|
||||
pub fn selected_text(&self, start: usize, end: usize) -> Option<&str> {
|
||||
let range = self.selection_range(start, end);
|
||||
self.text.get(range)
|
||||
}
|
||||
|
||||
pub fn selection_rects(&self, start: usize, end: usize) -> Vec<Rect> {
|
||||
let range = self.selection_range(start, end);
|
||||
if range.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rects = Vec::new();
|
||||
for line in &self.lines {
|
||||
let mut left = None::<f32>;
|
||||
let mut right = None::<f32>;
|
||||
for glyph in &self.glyphs[line.glyph_start..line.glyph_end] {
|
||||
if glyph.text_end <= range.start || glyph.text_start >= range.end {
|
||||
continue;
|
||||
}
|
||||
let glyph_left = glyph.position.x;
|
||||
let glyph_right = glyph.position.x + glyph.advance.max(0.0);
|
||||
left = Some(left.map_or(glyph_left, |current| current.min(glyph_left)));
|
||||
right = Some(right.map_or(glyph_right, |current| current.max(glyph_right)));
|
||||
}
|
||||
|
||||
if let (Some(left), Some(right)) = (left, right) {
|
||||
rects.push(Rect::new(
|
||||
left,
|
||||
line.rect.origin.y,
|
||||
(right - left).max(0.0),
|
||||
line.rect.size.height,
|
||||
));
|
||||
}
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
|
||||
let Some(selected_color) = self.selection_style.text_color else {
|
||||
return;
|
||||
};
|
||||
let range = self.selection_range(start, end);
|
||||
if range.is_empty() {
|
||||
return;
|
||||
}
|
||||
for glyph in &mut self.glyphs {
|
||||
if glyph.text_end > range.start && glyph.text_start < range.end {
|
||||
glyph.color = selected_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_for_position(&self, y: f32) -> Option<&PreparedTextLine> {
|
||||
let mut lines = self.lines.iter();
|
||||
let first = lines.next()?;
|
||||
if y < first.rect.origin.y {
|
||||
return Some(first);
|
||||
}
|
||||
|
||||
let mut last = first;
|
||||
for line in std::iter::once(first).chain(lines) {
|
||||
last = line;
|
||||
if y < line.rect.origin.y + line.rect.size.height {
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
Some(last)
|
||||
}
|
||||
|
||||
fn byte_offset_for_line_position(&self, line: &PreparedTextLine, x: f32) -> usize {
|
||||
if line.glyph_start == line.glyph_end {
|
||||
return line.text_start;
|
||||
}
|
||||
|
||||
let line_glyphs = &self.glyphs[line.glyph_start..line.glyph_end];
|
||||
let first_glyph = &line_glyphs[0];
|
||||
if x <= first_glyph.position.x {
|
||||
return first_glyph.text_start;
|
||||
}
|
||||
|
||||
for glyph in line_glyphs {
|
||||
let glyph_left = glyph.position.x;
|
||||
let glyph_right = glyph.position.x + glyph.advance.max(0.0);
|
||||
let midpoint = glyph_left + glyph.advance.max(0.0) * 0.5;
|
||||
if x < midpoint {
|
||||
return glyph.text_start;
|
||||
}
|
||||
if x < glyph_right {
|
||||
return glyph.text_end;
|
||||
}
|
||||
}
|
||||
|
||||
line.text_end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -226,3 +364,49 @@ impl SceneSnapshot {
|
||||
self.push_item(DisplayItem::LayerEnd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Color, Point, PreparedText, Rect};
|
||||
use crate::TextSelectionStyle;
|
||||
|
||||
#[test]
|
||||
fn prepared_text_hit_testing_clamps_to_nearest_cluster_boundary() {
|
||||
let text = PreparedText::monospace(
|
||||
"abcd",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(6.0, 24.0)), 0);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(13.0, 24.0)), 0);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(17.0, 24.0)), 1);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(33.0, 24.0)), 3);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(50.0, 24.0)), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_text_selection_rects_cover_selected_glyphs() {
|
||||
let mut text = PreparedText::monospace(
|
||||
"abcd",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
text.selection_style = TextSelectionStyle::new(Color::rgba(0x44, 0x66, 0xFF, 0xAA))
|
||||
.with_text_color(Color::rgb(0x11, 0x12, 0x1A));
|
||||
|
||||
assert_eq!(
|
||||
text.selection_rects(1, 3),
|
||||
vec![Rect::new(18.0, 20.0, 16.0, 16.0)]
|
||||
);
|
||||
assert_eq!(text.selected_text(1, 3), Some("bc"));
|
||||
text.apply_selected_text_color(1, 3);
|
||||
assert_eq!(text.glyphs[0].color, Color::rgb(0xFF, 0xFF, 0xFF));
|
||||
assert_eq!(text.glyphs[1].color, Color::rgb(0x11, 0x12, 0x1A));
|
||||
assert_eq!(text.glyphs[2].color, Color::rgb(0x11, 0x12, 0x1A));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::mem;
|
||||
use std::time::Instant;
|
||||
|
||||
use cosmic_text::{
|
||||
Attrs, Buffer, Color as CosmicColor, FontSystem, Metrics, Shaping, Style as CosmicStyle,
|
||||
@@ -6,16 +9,16 @@ use cosmic_text::{
|
||||
};
|
||||
use fontconfig::Fontconfig;
|
||||
|
||||
use crate::{Color, GlyphInstance, Point, PreparedText, UiSize};
|
||||
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, UiSize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextSpanWeight {
|
||||
Normal,
|
||||
Medium,
|
||||
@@ -46,7 +49,7 @@ impl TextFontFamily {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct TextSpan {
|
||||
pub text: String,
|
||||
pub color: Option<Color>,
|
||||
@@ -87,7 +90,7 @@ impl TextSpan {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextWrap {
|
||||
None,
|
||||
Word,
|
||||
@@ -102,6 +105,31 @@ impl TextWrap {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TextSelectionStyle {
|
||||
pub highlight_color: Color,
|
||||
pub text_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl TextSelectionStyle {
|
||||
pub const DEFAULT: Self = Self {
|
||||
highlight_color: Color::rgba(0x5B, 0x9D, 0xFF, 0x55),
|
||||
text_color: None,
|
||||
};
|
||||
|
||||
pub const fn new(highlight_color: Color) -> Self {
|
||||
Self {
|
||||
highlight_color,
|
||||
text_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_text_color(mut self, text_color: Color) -> Self {
|
||||
self.text_color = Some(text_color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
pub font_size: f32,
|
||||
@@ -112,6 +140,8 @@ pub struct TextStyle {
|
||||
pub wrap: TextWrap,
|
||||
pub align: TextAlign,
|
||||
pub max_lines: Option<usize>,
|
||||
pub selectable: bool,
|
||||
pub selection_style: TextSelectionStyle,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
@@ -125,6 +155,8 @@ impl TextStyle {
|
||||
wrap: TextWrap::None,
|
||||
align: TextAlign::Start,
|
||||
max_lines: None,
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,19 +189,44 @@ impl TextStyle {
|
||||
self.max_lines = Some(max_lines);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = selectable;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_selection_style(mut self, selection_style: TextSelectionStyle) -> Self {
|
||||
self.selection_style = selection_style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSystem {
|
||||
font_system: FontSystem,
|
||||
family_resolver: FontFamilyResolver,
|
||||
layout_cache: HashMap<u64, TextLayout>,
|
||||
frame_stats: TextFrameStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TextLayout {
|
||||
lines: Vec<PreparedTextLine>,
|
||||
glyphs: Vec<GlyphInstance>,
|
||||
size: UiSize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct TextFrameStats {
|
||||
pub requests: u32,
|
||||
pub cache_hits: u32,
|
||||
pub cache_misses: u32,
|
||||
pub output_glyphs: u32,
|
||||
pub family_resolve_ms: f64,
|
||||
pub buffer_build_ms: f64,
|
||||
pub glyph_collect_ms: f64,
|
||||
pub miss_ms: f64,
|
||||
}
|
||||
|
||||
struct FontFamilyResolver {
|
||||
fontconfig: Option<Fontconfig>,
|
||||
cache: HashMap<(TextFontFamily, TextSpanSlant), Option<String>>,
|
||||
@@ -189,51 +246,84 @@ impl TextSystem {
|
||||
Self {
|
||||
font_system,
|
||||
family_resolver,
|
||||
layout_cache: HashMap::new(),
|
||||
frame_stats: TextFrameStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_frame_stats(&mut self) {
|
||||
self.frame_stats = TextFrameStats::default();
|
||||
}
|
||||
|
||||
pub(crate) fn take_frame_stats(&mut self) -> TextFrameStats {
|
||||
mem::take(&mut self.frame_stats)
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
text: impl Into<String>,
|
||||
origin: Point,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
) -> PreparedText {
|
||||
self.prepare_spans([TextSpan::new(text)], origin, style)
|
||||
let spans = [TextSpan::new(text)];
|
||||
self.prepare_spans(&spans, origin, style, style.bounds)
|
||||
}
|
||||
|
||||
pub fn prepare_spans(
|
||||
&mut self,
|
||||
spans: impl IntoIterator<Item = TextSpan>,
|
||||
spans: &[TextSpan],
|
||||
origin: Point,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
bounds: Option<UiSize>,
|
||||
) -> PreparedText {
|
||||
let spans: Vec<TextSpan> = spans.into_iter().collect();
|
||||
let text = combined_text(&spans);
|
||||
let bounds = bounds.or(style.bounds);
|
||||
let text = combined_text(spans);
|
||||
let layout = self.layout(
|
||||
&spans,
|
||||
style.clone(),
|
||||
style.bounds.map(|bounds| bounds.width),
|
||||
style.bounds.map(|bounds| bounds.height),
|
||||
spans,
|
||||
style,
|
||||
bounds.map(|bounds| bounds.width),
|
||||
bounds.map(|bounds| bounds.height),
|
||||
);
|
||||
let glyphs = layout
|
||||
.glyphs
|
||||
.into_iter()
|
||||
.map(|glyph| GlyphInstance {
|
||||
glyph: glyph.glyph,
|
||||
position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y),
|
||||
advance: glyph.advance,
|
||||
color: glyph.color,
|
||||
cache_key: glyph.cache_key,
|
||||
text_start: glyph.text_start,
|
||||
text_end: glyph.text_end,
|
||||
})
|
||||
.collect();
|
||||
let lines = layout
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|line| PreparedTextLine {
|
||||
rect: Rect::new(
|
||||
origin.x + line.rect.origin.x,
|
||||
origin.y + line.rect.origin.y,
|
||||
line.rect.size.width,
|
||||
line.rect.size.height,
|
||||
),
|
||||
text_start: line.text_start,
|
||||
text_end: line.text_end,
|
||||
glyph_start: line.glyph_start,
|
||||
glyph_end: line.glyph_end,
|
||||
})
|
||||
.collect();
|
||||
|
||||
PreparedText {
|
||||
element_id: None,
|
||||
text,
|
||||
origin,
|
||||
bounds: style.bounds,
|
||||
bounds,
|
||||
font_size: style.font_size,
|
||||
line_height: style.line_height,
|
||||
color: style.color,
|
||||
selectable: style.selectable,
|
||||
selection_style: style.selection_style,
|
||||
lines,
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
@@ -241,66 +331,98 @@ impl TextSystem {
|
||||
pub fn measure(
|
||||
&mut self,
|
||||
text: &str,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> UiSize {
|
||||
self.measure_spans([TextSpan::new(text)], style, width, height)
|
||||
let spans = [TextSpan::new(text)];
|
||||
self.measure_spans(&spans, style, width, height)
|
||||
}
|
||||
|
||||
pub fn measure_spans(
|
||||
&mut self,
|
||||
spans: impl IntoIterator<Item = TextSpan>,
|
||||
style: TextStyle,
|
||||
spans: &[TextSpan],
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> UiSize {
|
||||
let spans: Vec<TextSpan> = spans.into_iter().collect();
|
||||
self.layout(&spans, style, width, height).size
|
||||
self.layout(spans, style, width, height).size
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
spans: &[TextSpan],
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
self.frame_stats.requests = self.frame_stats.requests.saturating_add(1);
|
||||
let cache_key = layout_cache_key(spans, style, width, height);
|
||||
if let Some(layout) = self.layout_cache.get(&cache_key) {
|
||||
self.frame_stats.cache_hits = self.frame_stats.cache_hits.saturating_add(1);
|
||||
self.frame_stats.output_glyphs = self
|
||||
.frame_stats
|
||||
.output_glyphs
|
||||
.saturating_add(layout.glyphs.len() as u32);
|
||||
return clamp_text_layout(layout.clone(), width, height);
|
||||
}
|
||||
self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1);
|
||||
let miss_started = Instant::now();
|
||||
|
||||
let uses_plain_text_fast_path = uses_plain_text_fast_path(spans);
|
||||
let family_resolve_started = Instant::now();
|
||||
let default_family = self.resolve_font_family(&style.font_family, TextSpanSlant::Normal);
|
||||
let resolved_span_families: Vec<_> = spans
|
||||
.iter()
|
||||
.map(|span| {
|
||||
self.resolve_font_family(
|
||||
span.font_family.as_ref().unwrap_or(&style.font_family),
|
||||
span.slant,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let resolved_span_families = (!uses_plain_text_fast_path).then(|| {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| {
|
||||
self.resolve_font_family(
|
||||
span.font_family.as_ref().unwrap_or(&style.font_family),
|
||||
span.slant,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
self.frame_stats.family_resolve_ms +=
|
||||
family_resolve_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let buffer_build_started = Instant::now();
|
||||
let mut buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height));
|
||||
{
|
||||
let mut borrowed = buffer.borrow_with(&mut self.font_system);
|
||||
borrowed.set_wrap(style.wrap.to_cosmic());
|
||||
borrowed.set_size(width, height);
|
||||
let default_attrs = default_attrs_for_style(&style, default_family.as_deref());
|
||||
borrowed.set_rich_text(
|
||||
spans
|
||||
.iter()
|
||||
.zip(resolved_span_families.iter())
|
||||
.map(|(span, resolved_family)| {
|
||||
(
|
||||
span.text.as_str(),
|
||||
attrs_for_span(span, &style, resolved_family.as_deref()),
|
||||
)
|
||||
}),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
borrowed.set_size(width, None);
|
||||
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
|
||||
if uses_plain_text_fast_path {
|
||||
borrowed.set_text(
|
||||
spans[0].text.as_str(),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
} else if let Some(resolved_span_families) = resolved_span_families.as_ref() {
|
||||
borrowed.set_rich_text(
|
||||
spans.iter().zip(resolved_span_families.iter()).map(
|
||||
|(span, resolved_family)| {
|
||||
(
|
||||
span.text.as_str(),
|
||||
attrs_for_span(span, style, resolved_family.as_deref()),
|
||||
)
|
||||
},
|
||||
),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let mut measured_width: f32 = 0.0;
|
||||
let mut measured_height: f32 = 0.0;
|
||||
let mut lines = Vec::new();
|
||||
let mut glyphs = Vec::new();
|
||||
let glyph_collect_started = Instant::now();
|
||||
for (line_index, run) in buffer.layout_runs().enumerate() {
|
||||
if matches!(style.max_lines, Some(max_lines) if line_index >= max_lines) {
|
||||
break;
|
||||
@@ -308,25 +430,47 @@ impl TextSystem {
|
||||
measured_width = measured_width.max(run.line_w);
|
||||
measured_height = measured_height.max(run.line_top + run.line_height);
|
||||
let x_offset = aligned_line_offset(style.align, width, run.line_w);
|
||||
let glyph_start = glyphs.len();
|
||||
glyphs.extend(run.glyphs.iter().map(move |glyph| {
|
||||
let physical = glyph.physical((x_offset, run.line_y), 1.0);
|
||||
GlyphInstance {
|
||||
glyph: run.text[glyph.start..glyph.end].to_string(),
|
||||
position: Point::new(physical.x as f32, physical.y as f32),
|
||||
advance: glyph.w,
|
||||
color: glyph.color_opt.map_or(style.color, color_from_cosmic),
|
||||
cache_key: Some(physical.cache_key),
|
||||
text_start: glyph.start,
|
||||
text_end: glyph.end,
|
||||
}
|
||||
}));
|
||||
let glyph_end = glyphs.len();
|
||||
let text_start = run.glyphs.first().map_or(0, |glyph| glyph.start);
|
||||
let text_end = run.glyphs.last().map_or(text_start, |glyph| glyph.end);
|
||||
lines.push(PreparedTextLine {
|
||||
rect: Rect::new(x_offset, run.line_top, run.line_w.max(0.0), run.line_height),
|
||||
text_start,
|
||||
text_end,
|
||||
glyph_start,
|
||||
glyph_end,
|
||||
});
|
||||
}
|
||||
self.frame_stats.glyph_collect_ms +=
|
||||
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let measured_width = width.map_or(measured_width, |limit| measured_width.min(limit));
|
||||
let measured_height = height.map_or(measured_height, |limit| measured_height.min(limit));
|
||||
|
||||
TextLayout {
|
||||
let layout = TextLayout {
|
||||
lines,
|
||||
glyphs,
|
||||
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
|
||||
};
|
||||
self.frame_stats.output_glyphs = self
|
||||
.frame_stats
|
||||
.output_glyphs
|
||||
.saturating_add(layout.glyphs.len() as u32);
|
||||
self.frame_stats.miss_ms += miss_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
if self.layout_cache.len() >= 256 {
|
||||
self.layout_cache.clear();
|
||||
}
|
||||
self.layout_cache.insert(cache_key, layout.clone());
|
||||
clamp_text_layout(layout, width, height)
|
||||
}
|
||||
|
||||
fn resolve_font_family(
|
||||
@@ -360,6 +504,63 @@ fn combined_text(spans: &[TextSpan]) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn layout_cache_key(
|
||||
spans: &[TextSpan],
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
_height: Option<f32>,
|
||||
) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
spans.hash(&mut hasher);
|
||||
style.font_size.to_bits().hash(&mut hasher);
|
||||
style.line_height.to_bits().hash(&mut hasher);
|
||||
style.color.hash(&mut hasher);
|
||||
style.font_family.hash(&mut hasher);
|
||||
style.wrap.hash(&mut hasher);
|
||||
style.align.hash(&mut hasher);
|
||||
style.max_lines.hash(&mut hasher);
|
||||
quantized_layout_dimension(width).hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn clamp_text_layout(
|
||||
mut layout: TextLayout,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
if let Some(width) = width {
|
||||
layout.size.width = layout.size.width.min(width.max(0.0));
|
||||
}
|
||||
if let Some(height) = height {
|
||||
layout.size.height = layout.size.height.min(height.max(0.0));
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
fn quantized_layout_dimension(value: Option<f32>) -> Option<u32> {
|
||||
const LAYOUT_CACHE_BUCKET_PX: f32 = 8.0;
|
||||
value.map(|value| {
|
||||
(value / LAYOUT_CACHE_BUCKET_PX)
|
||||
.round()
|
||||
.mul_add(LAYOUT_CACHE_BUCKET_PX, 0.0)
|
||||
.max(0.0)
|
||||
.to_bits()
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_plain_text_fast_path(spans: &[TextSpan]) -> bool {
|
||||
matches!(
|
||||
spans,
|
||||
[TextSpan {
|
||||
color: None,
|
||||
weight: TextSpanWeight::Normal,
|
||||
slant: TextSpanSlant::Normal,
|
||||
font_family: None,
|
||||
..
|
||||
}]
|
||||
)
|
||||
}
|
||||
|
||||
fn default_attrs_for_style<'a>(
|
||||
style: &'a TextStyle,
|
||||
resolved_family: Option<&'a str>,
|
||||
@@ -617,13 +818,14 @@ mod tests {
|
||||
.with_wrap(TextWrap::Word);
|
||||
let unclamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style.clone(),
|
||||
&style,
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
let clamped_style = style.with_max_lines(2);
|
||||
let clamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style.with_max_lines(2),
|
||||
&clamped_style,
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
@@ -638,13 +840,13 @@ mod tests {
|
||||
let start = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
&TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0)),
|
||||
);
|
||||
let centered = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
&TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0))
|
||||
.with_align(TextAlign::Center),
|
||||
);
|
||||
@@ -656,19 +858,18 @@ mod tests {
|
||||
#[test]
|
||||
fn rich_spans_preserve_per_glyph_colors() {
|
||||
let mut text_system = TextSystem::new();
|
||||
let prepared = text_system.prepare_spans(
|
||||
[
|
||||
TextSpan::new("Red ").color(Color::rgb(0xFF, 0x33, 0x33)),
|
||||
TextSpan::new("Blue")
|
||||
.color(Color::rgb(0x33, 0x66, 0xFF))
|
||||
.weight(TextSpanWeight::Bold)
|
||||
.slant(TextSpanSlant::Italic),
|
||||
],
|
||||
Point::new(0.0, 0.0),
|
||||
TextStyle::new(18.0, Color::rgb(0xEE, 0xEE, 0xEE))
|
||||
.with_wrap(TextWrap::Word)
|
||||
.with_bounds(UiSize::new(240.0, 80.0)),
|
||||
);
|
||||
let spans = [
|
||||
TextSpan::new("Red ").color(Color::rgb(0xFF, 0x33, 0x33)),
|
||||
TextSpan::new("Blue")
|
||||
.color(Color::rgb(0x33, 0x66, 0xFF))
|
||||
.weight(TextSpanWeight::Bold)
|
||||
.slant(TextSpanSlant::Italic),
|
||||
];
|
||||
let style = TextStyle::new(18.0, Color::rgb(0xEE, 0xEE, 0xEE))
|
||||
.with_wrap(TextWrap::Word)
|
||||
.with_bounds(UiSize::new(240.0, 80.0));
|
||||
let prepared =
|
||||
text_system.prepare_spans(&spans, Point::new(0.0, 0.0), &style, style.bounds);
|
||||
|
||||
assert!(
|
||||
prepared
|
||||
|
||||
@@ -20,6 +20,13 @@ pub enum FlexDirection {
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum CursorIcon {
|
||||
Default,
|
||||
Pointer,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Edges {
|
||||
pub top: f32,
|
||||
@@ -65,6 +72,7 @@ pub struct Style {
|
||||
pub padding: Edges,
|
||||
pub background: Option<Color>,
|
||||
pub pointer_events: bool,
|
||||
pub cursor: Option<CursorIcon>,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@@ -78,6 +86,7 @@ impl Default for Style {
|
||||
padding: Edges::ZERO,
|
||||
background: None,
|
||||
pointer_events: true,
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +198,11 @@ impl Element {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
|
||||
self.style.cursor = Some(cursor);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: Element) -> Self {
|
||||
self.assert_container();
|
||||
self.children.push(child);
|
||||
|
||||
@@ -200,7 +200,7 @@ impl WindowUpdate {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn apply_to(&self, spec: &mut WindowSpec) {
|
||||
pub fn apply_to(&self, spec: &mut WindowSpec) {
|
||||
if let Some(title) = &self.title {
|
||||
spec.title = title.clone();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ libc = "0.2"
|
||||
raw-window-handle = "0.6"
|
||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
||||
ruin_ui = { path = "../ui" }
|
||||
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
|
||||
tracing = "0.1"
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::ffi::c_void;
|
||||
use std::fs::File;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Write;
|
||||
use std::num::NonZeroU32;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::ptr::NonNull;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use raw_window_handle::{
|
||||
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle,
|
||||
RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
|
||||
};
|
||||
use ruin_ui::{Point, PointerButton, PointerEvent, PointerEventKind, UiSize, WindowSpec};
|
||||
use ruin_runtime::channel::mpsc;
|
||||
use ruin_runtime::{WorkerHandle, queue_future, queue_task, spawn_worker};
|
||||
use ruin_ui::{
|
||||
CursorIcon, PlatformEndpoint, PlatformEvent, PlatformRequest, PlatformRuntime, Point,
|
||||
PointerButton, PointerEvent, PointerEventKind, SceneSnapshot, UiRuntime, UiSize,
|
||||
WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||
};
|
||||
use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer};
|
||||
use tracing::{debug, trace};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
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_client::protocol::{
|
||||
wl_callback, wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface,
|
||||
};
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child,
|
||||
};
|
||||
use wayland_protocols::wp::cursor_shape::v1::client::{
|
||||
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
|
||||
};
|
||||
use wayland_protocols::wp::primary_selection::zv1::client::{
|
||||
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
|
||||
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
|
||||
};
|
||||
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -59,6 +83,46 @@ pub struct WaylandWindow {
|
||||
state: State,
|
||||
}
|
||||
|
||||
struct WindowWorkerHandle {
|
||||
command_tx: mpsc::UnboundedSender<WindowWorkerCommand>,
|
||||
_worker: WorkerHandle,
|
||||
}
|
||||
|
||||
struct WindowRecord {
|
||||
spec: WindowSpec,
|
||||
lifecycle: WindowLifecycle,
|
||||
latest_scene: Option<SceneSnapshot>,
|
||||
worker: Option<WindowWorkerHandle>,
|
||||
}
|
||||
|
||||
struct WindowWorkerState {
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
window: WaylandWindow,
|
||||
renderer: WgpuSceneRenderer,
|
||||
latest_scene: Option<SceneSnapshot>,
|
||||
opened_emitted: bool,
|
||||
close_requested_emitted: bool,
|
||||
closed_emitted: bool,
|
||||
shutdown_requested: bool,
|
||||
pending_viewport: Option<UiSize>,
|
||||
viewport_request_in_flight: Option<UiSize>,
|
||||
event_tx: mpsc::UnboundedSender<PlatformEvent>,
|
||||
internal_tx: mpsc::UnboundedSender<InternalBackendEvent>,
|
||||
}
|
||||
|
||||
enum WindowWorkerCommand {
|
||||
ReplaceScene(SceneSnapshot),
|
||||
SetPrimarySelectionText(String),
|
||||
SetCursorIcon(CursorIcon),
|
||||
ApplySpec(WindowSpec),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
enum InternalBackendEvent {
|
||||
Closed { window_id: WindowId },
|
||||
}
|
||||
|
||||
struct State {
|
||||
running: bool,
|
||||
_connection: Connection,
|
||||
@@ -68,13 +132,25 @@ struct State {
|
||||
_toplevel: xdg_toplevel::XdgToplevel,
|
||||
_wm_base: xdg_wm_base::XdgWmBase,
|
||||
_seat: wl_seat::WlSeat,
|
||||
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
||||
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
||||
primary_selection_manager:
|
||||
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
|
||||
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
|
||||
qh: QueueHandle<State>,
|
||||
pointer: Option<wl_pointer::WlPointer>,
|
||||
current_size: (u32, u32),
|
||||
configured: bool,
|
||||
pending_size: Option<(u32, u32)>,
|
||||
needs_redraw: bool,
|
||||
frame_callback: Option<wl_callback::WlCallback>,
|
||||
pointer_position: Option<Point>,
|
||||
pending_pointer_events: Vec<PointerEvent>,
|
||||
primary_selection_source: Option<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1>,
|
||||
primary_selection_text: Option<String>,
|
||||
last_selection_serial: Option<u32>,
|
||||
last_pointer_enter_serial: Option<u32>,
|
||||
cursor_icon: CursorIcon,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -83,6 +159,25 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
|
||||
match icon {
|
||||
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
|
||||
CursorIcon::Pointer => wp_cursor_shape_device_v1::Shape::Pointer,
|
||||
CursorIcon::Text => wp_cursor_shape_device_v1::Shape::Text,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_cursor_icon(state: &mut State) {
|
||||
let Some(device) = state.cursor_shape_device.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(serial) = state.last_pointer_enter_serial else {
|
||||
return;
|
||||
};
|
||||
device.set_shape(serial, wayland_cursor_shape(state.cursor_icon));
|
||||
let _ = state._connection.flush();
|
||||
}
|
||||
|
||||
impl WaylandWindow {
|
||||
pub fn open(spec: WindowSpec) -> Result<Self, Box<dyn Error>> {
|
||||
let connection = Connection::connect_to_env()?;
|
||||
@@ -92,6 +187,13 @@ impl WaylandWindow {
|
||||
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 cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok();
|
||||
let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok();
|
||||
let primary_selection_device = primary_selection_manager.as_ref().map(
|
||||
|manager: &zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1| {
|
||||
manager.get_device(&seat, &qh, ())
|
||||
},
|
||||
);
|
||||
let surface = compositor.create_surface(&qh, ());
|
||||
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
|
||||
let toplevel = xdg_surface.get_toplevel(&qh, ());
|
||||
@@ -133,13 +235,24 @@ impl WaylandWindow {
|
||||
_toplevel: toplevel,
|
||||
_wm_base: wm_base,
|
||||
_seat: seat,
|
||||
cursor_shape_manager,
|
||||
cursor_shape_device: None,
|
||||
primary_selection_manager,
|
||||
primary_selection_device,
|
||||
qh,
|
||||
pointer: None,
|
||||
current_size: (initial_width, initial_height),
|
||||
configured: false,
|
||||
pending_size: None,
|
||||
needs_redraw: false,
|
||||
frame_callback: None,
|
||||
pointer_position: None,
|
||||
pending_pointer_events: Vec::new(),
|
||||
primary_selection_source: None,
|
||||
primary_selection_text: None,
|
||||
last_selection_serial: None,
|
||||
last_pointer_enter_serial: None,
|
||||
cursor_icon: CursorIcon::Default,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -282,6 +395,83 @@ impl WaylandWindow {
|
||||
self.state.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_primary_selection_text(
|
||||
&mut self,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let text = text.into();
|
||||
let Some(primary_selection_manager) = self.state.primary_selection_manager.as_ref() else {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
"Wayland compositor does not expose primary selection; skipping copy"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(primary_selection_device) = self.state.primary_selection_device.as_ref() else {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
"Wayland seat does not expose a primary selection device; skipping copy"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(serial) = self.state.last_selection_serial else {
|
||||
return Err(Box::new(std::io::Error::other(
|
||||
"primary selection copy requires a recent input serial",
|
||||
)));
|
||||
};
|
||||
|
||||
let source = primary_selection_manager.create_source(&self.state.qh, ());
|
||||
source.offer("text/plain;charset=utf-8".to_owned());
|
||||
source.offer("text/plain".to_owned());
|
||||
primary_selection_device.set_selection(Some(&source), serial);
|
||||
self.state.primary_selection_source = Some(source);
|
||||
self.state.primary_selection_text = Some(text);
|
||||
self.state._connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_cursor_icon(&mut self, cursor: CursorIcon) -> Result<(), Box<dyn Error>> {
|
||||
self.state.cursor_icon = cursor;
|
||||
apply_cursor_icon(&mut self.state);
|
||||
self.state._connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn presentation_ready(&self) -> bool {
|
||||
self.state.frame_callback.is_none()
|
||||
}
|
||||
|
||||
fn arm_frame_callback(&mut self) {
|
||||
if self.state.frame_callback.is_none() {
|
||||
self.state.frame_callback = Some(self.state._surface.frame(&self.state.qh, ()));
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_frame_callback(&mut self) {
|
||||
self.state.frame_callback = None;
|
||||
}
|
||||
|
||||
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
|
||||
self.state._toplevel.set_title(spec.title.clone());
|
||||
if let Some(app_id) = spec.app_id.as_ref() {
|
||||
self.state._toplevel.set_app_id(app_id.clone());
|
||||
}
|
||||
apply_size_constraints(&self.state._toplevel, spec);
|
||||
if spec.maximized {
|
||||
self.state._toplevel.set_maximized();
|
||||
} else {
|
||||
self.state._toplevel.unset_maximized();
|
||||
}
|
||||
if spec.fullscreen {
|
||||
self.state._toplevel.set_fullscreen(None);
|
||||
} else {
|
||||
self.state._toplevel.unset_fullscreen();
|
||||
}
|
||||
self.state._surface.commit();
|
||||
self.state._connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn drain_pointer_events(&mut self) -> Vec<PointerEvent> {
|
||||
std::mem::take(&mut self.state.pending_pointer_events)
|
||||
}
|
||||
@@ -314,6 +504,568 @@ impl WaylandWindow {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_wayland_platform() -> PlatformRuntime {
|
||||
PlatformRuntime::custom(|endpoint| spawn_worker(move || run_wayland_platform(endpoint), || {}))
|
||||
}
|
||||
|
||||
pub fn start_wayland_ui() -> UiRuntime {
|
||||
UiRuntime::from_platform(start_wayland_platform())
|
||||
}
|
||||
|
||||
fn run_wayland_platform(mut endpoint: PlatformEndpoint) {
|
||||
let state = Rc::new(RefCell::new(WaylandBackendState {
|
||||
events: endpoint.events.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
let (internal_tx, mut internal_rx) = mpsc::unbounded_channel::<InternalBackendEvent>();
|
||||
|
||||
queue_future({
|
||||
let state = Rc::clone(&state);
|
||||
let internal_tx = internal_tx.clone();
|
||||
async move {
|
||||
while let Some(request) = endpoint.commands.recv().await {
|
||||
match request {
|
||||
PlatformRequest::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, &internal_tx, window_id, spec);
|
||||
}
|
||||
PlatformRequest::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, &internal_tx, window_id, update);
|
||||
}
|
||||
PlatformRequest::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformRequest::SetPrimarySelectionText { window_id, text } => {
|
||||
handle_set_primary_selection_text(&state, window_id, text);
|
||||
}
|
||||
PlatformRequest::SetCursorIcon { window_id, cursor } => {
|
||||
handle_set_cursor_icon(&state, window_id, cursor);
|
||||
}
|
||||
PlatformRequest::EmitCloseRequested { window_id } => {
|
||||
emit_wayland_event(&state, PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformRequest::EmitPointerEvent { window_id, event } => {
|
||||
emit_wayland_event(&state, PlatformEvent::Pointer { window_id, event });
|
||||
}
|
||||
PlatformRequest::Shutdown => {
|
||||
shutdown_wayland_backend(&state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
queue_future({
|
||||
let state = Rc::clone(&state);
|
||||
async move {
|
||||
while let Some(event) = internal_rx.recv().await {
|
||||
let InternalBackendEvent::Closed { window_id } = event;
|
||||
if let Some(record) = state.borrow_mut().windows.get_mut(&window_id) {
|
||||
record.worker = None;
|
||||
record.lifecycle = WindowLifecycle::LogicalClosed;
|
||||
record.spec.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
struct WaylandBackendState {
|
||||
events: mpsc::UnboundedSender<PlatformEvent>,
|
||||
windows: BTreeMap<WindowId, WindowRecord>,
|
||||
}
|
||||
|
||||
fn handle_create_window(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
internal_tx: &mpsc::UnboundedSender<InternalBackendEvent>,
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
) {
|
||||
let mut record = WindowRecord {
|
||||
spec: spec.clone(),
|
||||
lifecycle: WindowLifecycle::LogicalClosed,
|
||||
latest_scene: None,
|
||||
worker: None,
|
||||
};
|
||||
if spec.open {
|
||||
record.worker = Some(spawn_window_worker(
|
||||
window_id,
|
||||
spec.clone(),
|
||||
None,
|
||||
state.borrow().events.clone(),
|
||||
internal_tx.clone(),
|
||||
));
|
||||
record.lifecycle = WindowLifecycle::Opening;
|
||||
}
|
||||
state.borrow_mut().windows.insert(window_id, record);
|
||||
}
|
||||
|
||||
fn handle_update_window(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
internal_tx: &mpsc::UnboundedSender<InternalBackendEvent>,
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
) {
|
||||
let mut spawn_spec = None;
|
||||
let mut shutdown_worker = None;
|
||||
let mut apply_spec = None;
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(record) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
let was_open = record.spec.open;
|
||||
update.apply_to(&mut record.spec);
|
||||
match (was_open, record.spec.open) {
|
||||
(false, true) => {
|
||||
record.lifecycle = WindowLifecycle::Opening;
|
||||
spawn_spec = Some((record.spec.clone(), record.latest_scene.clone()));
|
||||
}
|
||||
(true, false) => {
|
||||
shutdown_worker = record.worker.take();
|
||||
record.lifecycle = WindowLifecycle::Closing;
|
||||
}
|
||||
_ => {
|
||||
if let Some(worker) = record.worker.as_ref() {
|
||||
apply_spec = Some((worker.command_tx.clone(), record.spec.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(worker) = shutdown_worker {
|
||||
let _ = worker.command_tx.send(WindowWorkerCommand::Shutdown);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((spec, latest_scene)) = spawn_spec {
|
||||
let worker = spawn_window_worker(
|
||||
window_id,
|
||||
spec,
|
||||
latest_scene,
|
||||
state.borrow().events.clone(),
|
||||
internal_tx.clone(),
|
||||
);
|
||||
if let Some(record) = state.borrow_mut().windows.get_mut(&window_id) {
|
||||
record.worker = Some(worker);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((command_tx, spec)) = apply_spec {
|
||||
let _ = command_tx.send(WindowWorkerCommand::ApplySpec(spec));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_replace_scene(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
) {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = window_id.raw(),
|
||||
scene_version = scene.version,
|
||||
width = scene.logical_size.width,
|
||||
height = scene.logical_size.height,
|
||||
"received scene replacement"
|
||||
);
|
||||
let mut command_tx = None;
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(record) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
record.latest_scene = Some(scene.clone());
|
||||
if let Some(worker) = record.worker.as_ref() {
|
||||
command_tx = Some(worker.command_tx.clone());
|
||||
}
|
||||
}
|
||||
if let Some(command_tx) = command_tx {
|
||||
let _ = command_tx.send(WindowWorkerCommand::ReplaceScene(scene));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_primary_selection_text(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
) {
|
||||
let command_tx = state.borrow().windows.get(&window_id).and_then(|record| {
|
||||
record
|
||||
.worker
|
||||
.as_ref()
|
||||
.map(|worker| worker.command_tx.clone())
|
||||
});
|
||||
if let Some(command_tx) = command_tx {
|
||||
let _ = command_tx.send(WindowWorkerCommand::SetPrimarySelectionText(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_cursor_icon(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
cursor: CursorIcon,
|
||||
) {
|
||||
let command_tx = {
|
||||
let state_ref = state.borrow();
|
||||
state_ref
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.and_then(|record| record.worker.as_ref())
|
||||
.map(|worker| worker.command_tx.clone())
|
||||
};
|
||||
if let Some(command_tx) = command_tx {
|
||||
let _ = command_tx.send(WindowWorkerCommand::SetCursorIcon(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_wayland_backend(state: &Rc<RefCell<WaylandBackendState>>) {
|
||||
let workers: Vec<mpsc::UnboundedSender<WindowWorkerCommand>> = state
|
||||
.borrow()
|
||||
.windows
|
||||
.values()
|
||||
.filter_map(|record| {
|
||||
record
|
||||
.worker
|
||||
.as_ref()
|
||||
.map(|worker| worker.command_tx.clone())
|
||||
})
|
||||
.collect();
|
||||
for command_tx in workers {
|
||||
let _ = command_tx.send(WindowWorkerCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_window_worker(
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
latest_scene: Option<SceneSnapshot>,
|
||||
event_tx: mpsc::UnboundedSender<PlatformEvent>,
|
||||
internal_tx: mpsc::UnboundedSender<InternalBackendEvent>,
|
||||
) -> WindowWorkerHandle {
|
||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<WindowWorkerCommand>();
|
||||
let worker = spawn_worker(
|
||||
move || {
|
||||
let window = match WaylandWindow::open(spec.clone()) {
|
||||
Ok(window) => window,
|
||||
Err(_) => {
|
||||
let _ = event_tx.send(PlatformEvent::Closed { window_id });
|
||||
let _ = internal_tx.send(InternalBackendEvent::Closed { window_id });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let renderer = match WgpuSceneRenderer::new(
|
||||
window.surface_target(),
|
||||
spec.requested_inner_size
|
||||
.unwrap_or_else(|| UiSize::new(800.0, 500.0))
|
||||
.width as u32,
|
||||
spec.requested_inner_size
|
||||
.unwrap_or_else(|| UiSize::new(800.0, 500.0))
|
||||
.height as u32,
|
||||
) {
|
||||
Ok(renderer) => renderer,
|
||||
Err(_) => {
|
||||
let _ = event_tx.send(PlatformEvent::Closed { window_id });
|
||||
let _ = internal_tx.send(InternalBackendEvent::Closed { window_id });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let state = Rc::new(RefCell::new(WindowWorkerState {
|
||||
window_id,
|
||||
spec: spec.clone(),
|
||||
window,
|
||||
renderer,
|
||||
latest_scene,
|
||||
opened_emitted: false,
|
||||
close_requested_emitted: false,
|
||||
closed_emitted: false,
|
||||
shutdown_requested: false,
|
||||
pending_viewport: None,
|
||||
viewport_request_in_flight: None,
|
||||
event_tx,
|
||||
internal_tx,
|
||||
}));
|
||||
|
||||
queue_future({
|
||||
let state = Rc::clone(&state);
|
||||
async move {
|
||||
while let Some(command) = command_rx.recv().await {
|
||||
match command {
|
||||
WindowWorkerCommand::ReplaceScene(scene) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
scene_version = scene.version,
|
||||
width = scene.logical_size.width,
|
||||
height = scene.logical_size.height,
|
||||
"worker accepted scene"
|
||||
);
|
||||
state_ref.latest_scene = Some(scene);
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
WindowWorkerCommand::SetPrimarySelectionText(text) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
if let Err(error) =
|
||||
state_ref.window.set_primary_selection_text(text)
|
||||
{
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
error = %error,
|
||||
"failed to set primary selection text"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowWorkerCommand::SetCursorIcon(cursor) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
if let Err(error) = state_ref.window.set_cursor_icon(cursor) {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::cursor",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
error = %error,
|
||||
"failed to set cursor icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowWorkerCommand::ApplySpec(spec) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
state_ref.spec = spec.clone();
|
||||
if state_ref.window.apply_spec(&spec).is_ok() {
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
}
|
||||
WindowWorkerCommand::Shutdown => {
|
||||
state.borrow_mut().shutdown_requested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
queue_task({
|
||||
let state = Rc::clone(&state);
|
||||
move || pump_window_worker(state)
|
||||
});
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
WindowWorkerHandle {
|
||||
command_tx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
|
||||
fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
let mut reschedule = true;
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
if state_ref.shutdown_requested
|
||||
|| state_ref
|
||||
.window
|
||||
.wait_for_events(Duration::from_millis(16))
|
||||
.is_err()
|
||||
{
|
||||
emit_window_closed(&mut state_ref, false);
|
||||
reschedule = false;
|
||||
} else {
|
||||
for event in state_ref.window.drain_pointer_events() {
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::Pointer {
|
||||
window_id: state_ref.window_id,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
if !state_ref.window.is_running() {
|
||||
emit_window_closed(&mut state_ref, true);
|
||||
reschedule = false;
|
||||
} else if let Some(frame) = state_ref.window.prepare_frame() {
|
||||
let current_viewport = UiSize::new(frame.width as f32, frame.height as f32);
|
||||
if frame.resized {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
width = current_viewport.width,
|
||||
height = current_viewport.height,
|
||||
"worker observed resized frame"
|
||||
);
|
||||
state_ref.renderer.resize(frame.width, frame.height);
|
||||
state_ref.window.request_redraw();
|
||||
state_ref.pending_viewport = Some(current_viewport);
|
||||
if state_ref
|
||||
.latest_scene
|
||||
.as_ref()
|
||||
.is_none_or(|scene| scene.logical_size != current_viewport)
|
||||
|| state_ref.viewport_request_in_flight.is_some()
|
||||
{
|
||||
maybe_request_pending_viewport(&mut state_ref);
|
||||
} else {
|
||||
state_ref.pending_viewport = None;
|
||||
}
|
||||
}
|
||||
if !state_ref.opened_emitted {
|
||||
state_ref.opened_emitted = true;
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::Opened {
|
||||
window_id: state_ref.window_id,
|
||||
});
|
||||
}
|
||||
let scene = state_ref.latest_scene.clone();
|
||||
if let Some(scene) = scene.as_ref() {
|
||||
if !state_ref.window.presentation_ready() {
|
||||
// Wait for the compositor frame callback before attempting another present.
|
||||
} else if scene.logical_size != current_viewport {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
scene_version = scene.version,
|
||||
scene_width = scene.logical_size.width,
|
||||
scene_height = scene.logical_size.height,
|
||||
viewport_width = current_viewport.width,
|
||||
viewport_height = current_viewport.height,
|
||||
"scene size does not match current viewport"
|
||||
);
|
||||
state_ref.pending_viewport = Some(current_viewport);
|
||||
let mut preview_scene = scene.clone();
|
||||
preview_scene.logical_size = current_viewport;
|
||||
state_ref.window.arm_frame_callback();
|
||||
if state_ref.renderer.render(&preview_scene).is_ok() {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
scene_version = scene.version,
|
||||
width = current_viewport.width,
|
||||
height = current_viewport.height,
|
||||
"presented scaled preview scene for current viewport"
|
||||
);
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::FramePresented {
|
||||
window_id: state_ref.window_id,
|
||||
scene_version: scene.version,
|
||||
item_count: scene.item_count(),
|
||||
});
|
||||
finish_presented_viewport_request(&mut state_ref, scene.logical_size);
|
||||
} else {
|
||||
state_ref.window.clear_frame_callback();
|
||||
}
|
||||
} else {
|
||||
state_ref.window.arm_frame_callback();
|
||||
match state_ref.renderer.render(scene) {
|
||||
Ok(()) => {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
scene_version = scene.version,
|
||||
width = scene.logical_size.width,
|
||||
height = scene.logical_size.height,
|
||||
"presented matching scene"
|
||||
);
|
||||
finish_presented_viewport_request(
|
||||
&mut state_ref,
|
||||
scene.logical_size,
|
||||
);
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::FramePresented {
|
||||
window_id: state_ref.window_id,
|
||||
scene_version: scene.version,
|
||||
item_count: scene.item_count(),
|
||||
});
|
||||
}
|
||||
Err(RenderError::Lost | RenderError::Outdated) => {
|
||||
state_ref.window.clear_frame_callback();
|
||||
state_ref.renderer.resize(frame.width, frame.height);
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
Err(
|
||||
RenderError::Timeout
|
||||
| RenderError::Occluded
|
||||
| RenderError::Validation,
|
||||
) => {
|
||||
state_ref.window.clear_frame_callback();
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if state_ref.viewport_request_in_flight.is_none()
|
||||
&& state_ref.pending_viewport.is_some()
|
||||
{
|
||||
maybe_request_pending_viewport(&mut state_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reschedule {
|
||||
queue_task(move || pump_window_worker(state));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_window_closed(state: &mut WindowWorkerState, emit_close_requested: bool) {
|
||||
if emit_close_requested && !state.close_requested_emitted {
|
||||
state.close_requested_emitted = true;
|
||||
let _ = state.event_tx.send(PlatformEvent::CloseRequested {
|
||||
window_id: state.window_id,
|
||||
});
|
||||
}
|
||||
if state.closed_emitted {
|
||||
return;
|
||||
}
|
||||
state.closed_emitted = true;
|
||||
let _ = state.event_tx.send(PlatformEvent::Closed {
|
||||
window_id: state.window_id,
|
||||
});
|
||||
let _ = state.internal_tx.send(InternalBackendEvent::Closed {
|
||||
window_id: state.window_id,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_window_configured(state: &WindowWorkerState, viewport: UiSize) {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
window_id = state.window_id.raw(),
|
||||
width = viewport.width,
|
||||
height = viewport.height,
|
||||
"emitting configured event to UI"
|
||||
);
|
||||
let _ = state.event_tx.send(PlatformEvent::Configured {
|
||||
window_id: state.window_id,
|
||||
configuration: WindowConfigured {
|
||||
actual_inner_size: viewport,
|
||||
scale_factor: 1.0,
|
||||
visible: state.spec.visible,
|
||||
maximized: state.spec.maximized,
|
||||
fullscreen: state.spec.fullscreen,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn maybe_request_pending_viewport(state: &mut WindowWorkerState) {
|
||||
if state.viewport_request_in_flight.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(pending_viewport) = state.pending_viewport else {
|
||||
return;
|
||||
};
|
||||
state.viewport_request_in_flight = Some(pending_viewport);
|
||||
emit_window_configured(state, pending_viewport);
|
||||
}
|
||||
|
||||
fn finish_presented_viewport_request(state: &mut WindowWorkerState, presented_viewport: UiSize) {
|
||||
if state.pending_viewport == Some(presented_viewport) {
|
||||
state.pending_viewport = None;
|
||||
}
|
||||
if state.viewport_request_in_flight == Some(presented_viewport) {
|
||||
state.viewport_request_in_flight = None;
|
||||
}
|
||||
if state.pending_viewport != Some(presented_viewport) {
|
||||
maybe_request_pending_viewport(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_wayland_event(state: &Rc<RefCell<WaylandBackendState>>, event: PlatformEvent) {
|
||||
let _ = state.borrow().events.send(event);
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
@@ -328,6 +1080,13 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||
|
||||
delegate_noop!(State: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(State: ignore wl_surface::WlSurface);
|
||||
delegate_noop!(State: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1);
|
||||
delegate_noop!(State: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1);
|
||||
delegate_noop!(
|
||||
State: ignore
|
||||
zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1
|
||||
);
|
||||
delegate_noop!(State: ignore zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1);
|
||||
|
||||
impl Dispatch<wl_seat::WlSeat, ()> for State {
|
||||
fn event(
|
||||
@@ -344,11 +1103,18 @@ impl Dispatch<wl_seat::WlSeat, ()> for State {
|
||||
};
|
||||
if capabilities.contains(wl_seat::Capability::Pointer) {
|
||||
if state.pointer.is_none() {
|
||||
state.pointer = Some(seat.get_pointer(qh, ()));
|
||||
let pointer = seat.get_pointer(qh, ());
|
||||
state.cursor_shape_device = state
|
||||
.cursor_shape_manager
|
||||
.as_ref()
|
||||
.map(|manager| manager.get_pointer(&pointer, qh, ()));
|
||||
state.pointer = Some(pointer);
|
||||
}
|
||||
} else {
|
||||
state.pointer = None;
|
||||
state.cursor_shape_device = None;
|
||||
state.pointer_position = None;
|
||||
state.last_pointer_enter_serial = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,11 +1131,22 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
|
||||
) {
|
||||
match event {
|
||||
wl_pointer::Event::Enter {
|
||||
serial,
|
||||
surface_x,
|
||||
surface_y,
|
||||
..
|
||||
} => {
|
||||
let position = Point::new(surface_x as f32, surface_y as f32);
|
||||
state.pointer_position = Some(position);
|
||||
state.last_pointer_enter_serial = Some(serial);
|
||||
apply_cursor_icon(state);
|
||||
state.pending_pointer_events.push(PointerEvent::new(
|
||||
0,
|
||||
position,
|
||||
PointerEventKind::Move,
|
||||
));
|
||||
}
|
||||
| wl_pointer::Event::Motion {
|
||||
wl_pointer::Event::Motion {
|
||||
surface_x,
|
||||
surface_y,
|
||||
..
|
||||
@@ -390,8 +1167,10 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
|
||||
PointerEventKind::LeaveWindow,
|
||||
));
|
||||
state.pointer_position = None;
|
||||
state.last_pointer_enter_serial = None;
|
||||
}
|
||||
wl_pointer::Event::Button {
|
||||
serial,
|
||||
button,
|
||||
state: button_state,
|
||||
..
|
||||
@@ -402,6 +1181,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
|
||||
if button != 0x110 {
|
||||
return;
|
||||
}
|
||||
state.last_selection_serial = Some(serial);
|
||||
let kind = match button_state {
|
||||
WEnum::Value(wl_pointer::ButtonState::Pressed) => PointerEventKind::Down {
|
||||
button: PointerButton::Primary,
|
||||
@@ -420,6 +1200,53 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, ()> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_data_device: &zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
|
||||
_event: zwp_primary_selection_device_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
event_created_child!(State, zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, [
|
||||
zwp_primary_selection_device_v1::EVT_DATA_OFFER_OPCODE
|
||||
=> (zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1, ())
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
data_source: &zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
|
||||
event: zwp_primary_selection_source_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
zwp_primary_selection_source_v1::Event::Send { mime_type, fd } => {
|
||||
if mime_type == "text/plain" || mime_type == "text/plain;charset=utf-8" {
|
||||
let mut file = File::from(fd);
|
||||
if let Some(text) = state.primary_selection_text.as_deref() {
|
||||
let _ = file.write_all(text.as_bytes());
|
||||
}
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
zwp_primary_selection_source_v1::Event::Cancelled => {
|
||||
if state.primary_selection_source.as_ref() == Some(data_source) {
|
||||
state.primary_selection_source = None;
|
||||
state.primary_selection_text = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
@@ -452,6 +1279,24 @@ impl Dispatch<xdg_surface::XdgSurface, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_callback::WlCallback, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
callback: &wl_callback::WlCallback,
|
||||
event: wl_callback::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
if state.frame_callback.as_ref() == Some(callback) {
|
||||
state.frame_callback = None;
|
||||
}
|
||||
state.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
@@ -476,6 +1321,12 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
|
||||
let height = NonZeroU32::new(height as u32)
|
||||
.map(NonZeroU32::get)
|
||||
.unwrap_or(state.current_size.1);
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
width,
|
||||
height,
|
||||
"received Wayland toplevel configure"
|
||||
);
|
||||
state.pending_size = Some((width, height));
|
||||
state.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1427,7 +1427,9 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{blend_rgba, build_vertices, text_texture_key};
|
||||
use ruin_ui::{Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use ruin_ui::{
|
||||
Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, TextSelectionStyle, UiSize,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
||||
@@ -1441,12 +1443,16 @@ mod tests {
|
||||
Color::rgb(0x44, 0x55, 0x66),
|
||||
);
|
||||
scene.push_text(PreparedText {
|
||||
element_id: None,
|
||||
text: "ignored".into(),
|
||||
origin: Point::new(4.0, 8.0),
|
||||
bounds: None,
|
||||
font_size: 16.0,
|
||||
line_height: 18.0,
|
||||
color: Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
lines: Vec::new(),
|
||||
glyphs: Vec::new(),
|
||||
});
|
||||
|
||||
@@ -1466,18 +1472,23 @@ mod tests {
|
||||
#[test]
|
||||
fn text_texture_key_ignores_absolute_origin() {
|
||||
let first = PreparedText {
|
||||
element_id: None,
|
||||
text: "cache me".into(),
|
||||
origin: Point::new(20.0, 30.0),
|
||||
bounds: Some(UiSize::new(120.0, 48.0)),
|
||||
font_size: 16.0,
|
||||
line_height: 20.0,
|
||||
color: Color::rgb(0xEE, 0xEE, 0xEE),
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
lines: Vec::new(),
|
||||
glyphs: vec![GlyphInstance {
|
||||
glyph: "c".into(),
|
||||
position: Point::new(24.0, 44.0),
|
||||
advance: 8.0,
|
||||
color: Color::rgb(0xEE, 0xEE, 0xEE),
|
||||
cache_key: None,
|
||||
text_start: 0,
|
||||
text_end: 1,
|
||||
}],
|
||||
};
|
||||
let second = PreparedText {
|
||||
|
||||
Reference in New Issue
Block a user