More text improvements, performance enhancements, input handling, text selection, wl cursors
This commit is contained in:
@@ -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