Functional parity with linux

This commit is contained in:
Will Temple
2026-05-15 13:31:59 -07:00
parent 861bf63621
commit 67400f1499
17 changed files with 2221 additions and 458 deletions

View File

@@ -270,20 +270,14 @@ fn layout_element(
&& !rects_overlap(rect, clip)
{
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
return layout_interaction_skeleton(
element,
rect,
corner_radius: 0.0,
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default),
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
path,
text_system,
perf_stats,
layout_cache,
);
}
// Incremental layout cache check.
@@ -295,6 +289,7 @@ fn layout_element(
subtree_hash,
avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.round() as u32,
clip_rect_bits: cache_clip_rect_bits(rect, clip_rect),
};
if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin;
@@ -888,6 +883,210 @@ fn layout_container_children(
children
}
fn layout_interaction_skeleton(
element: &Element,
rect: Rect,
path: LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> LayoutNode {
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,
corner_radius: uniform_corner_radius(&element.style, rect),
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return interaction;
}
if let Some(scroll_box) = element.scroll_box_node() {
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return interaction;
}
let gutter_width = scroll_box
.scrollbar
.gutter_width
.max(0.0)
.min(content.size.width);
let viewport_rect = Rect::new(
content.origin.x,
content.origin.y,
(content.size.width - gutter_width).max(0.0),
content.size.height,
);
interaction.clip_rect = Some(viewport_rect);
let content_size = intrinsic_container_content_size(
element,
UiSize::new(
viewport_rect.size.width.max(0.0),
viewport_rect.size.height.max(0.0),
),
text_system,
perf_stats,
layout_cache,
);
let content_height = content_size.height.max(viewport_rect.size.height);
let offset_y = scroll_box
.offset_y
.max(0.0)
.min((content_height - viewport_rect.size.height).max(0.0));
interaction.children = layout_interaction_skeleton_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
content_height,
),
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
content,
scroll_box.scrollbar,
content_height,
viewport_rect.size.height,
offset_y,
);
interaction.scroll_metrics = Some(ScrollMetrics {
viewport_rect,
content_height,
offset_y,
max_offset_y: (content_height - viewport_rect.size.height).max(0.0),
scrollbar_track,
scrollbar_thumb,
});
return interaction;
}
if element.text_node().is_some()
|| element.image_node().is_some()
|| element.children.is_empty()
{
return interaction;
}
let content = inset_rect(rect, content_insets(&element.style));
interaction.children = layout_interaction_skeleton_children(
element,
content,
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
interaction
}
fn layout_interaction_skeleton_children(
element: &Element,
content: Rect,
path: &LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main =
(main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
let explicit_main =
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
} else {
intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
layout_cache,
)
}
});
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
} else {
measured.main
};
let child_rect = child_rect(
content,
element.style.direction,
cursor,
child_main.max(0.0),
measured.cross,
);
children.push(layout_interaction_skeleton(
child,
child_rect,
path.child(index),
text_system,
perf_stats,
layout_cache,
));
cursor += child_main.max(0.0) + element.style.gap;
}
children
}
fn intrinsic_main_size(
child: &Element,
direction: FlexDirection,
@@ -1147,6 +1346,7 @@ struct LayoutCacheKey {
subtree_hash: u64,
avail_width_bits: u32,
avail_height_bits: u32,
clip_rect_bits: Option<(u32, u32, u32, u32)>,
}
struct CachedLayout {
@@ -1208,6 +1408,16 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
&& a.origin.y + a.size.height > b.origin.y
}
fn cache_clip_rect_bits(rect: Rect, clip_rect: Option<Rect>) -> Option<(u32, u32, u32, u32)> {
let clip = clip_rect?;
Some((
(clip.origin.x - rect.origin.x).round().to_bits(),
(clip.origin.y - rect.origin.y).round().to_bits(),
clip.size.width.round().to_bits(),
clip.size.height.round().to_bits(),
))
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 {
@@ -1599,7 +1809,9 @@ fn child_rect(
#[cfg(test)]
mod tests {
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache};
use super::{
LayoutCache, LayoutSnapshot, layout_scene, layout_snapshot, layout_snapshot_with_cache,
};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap};
@@ -2254,7 +2466,6 @@ mod tests {
#[test]
fn scroll_culling_skips_off_screen_children() {
use crate::tree::ScrollbarStyle;
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
@@ -2296,4 +2507,108 @@ mod tests {
"expected >= 4 text items visible, got {text_items}"
);
}
#[test]
fn cached_scroll_container_recomputes_visible_children_after_offset_changes() {
fn scroll_root(offset_y: f32) -> Element {
let mut list = Element::column().gap(0.0);
for i in 0..20 {
list = list.child(
Element::new()
.height(40.0)
.child(Element::text(format!("item {i:02}"), text_style())),
);
}
Element::new().child(
Element::scroll_box(offset_y)
.width(400.0)
.height(200.0)
.child(list),
)
}
fn visible_texts(snapshot: &LayoutSnapshot) -> Vec<&str> {
snapshot
.scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect()
}
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let size = UiSize::new(400.0, 200.0);
let first = layout_snapshot_with_cache(
1,
size,
&scroll_root(0.0),
&mut text_system,
&mut layout_cache,
);
let second = layout_snapshot_with_cache(
2,
size,
&scroll_root(240.0),
&mut text_system,
&mut layout_cache,
);
let first_visible = visible_texts(&first);
let second_visible = visible_texts(&second);
assert!(first_visible.iter().any(|text| text.contains("item 00")));
assert!(
second_visible.iter().any(|text| text.contains("item 06")),
"expected scrolled snapshot to include newly visible rows, got {second_visible:?}"
);
assert!(
!second_visible.iter().any(|text| text.contains("item 00")),
"expected initial rows to be culled after scrolling, got {second_visible:?}"
);
}
#[test]
fn culled_container_keeps_descendant_geometry_for_scroll_into_view() {
let target_id = ElementId::new(42);
let root = Element::new().child(
Element::scroll_box(0.0).width(400.0).height(120.0).child(
Element::column()
.child(
Element::column()
.height(160.0)
.child(Element::text("visible group", text_style())),
)
.child(
Element::column().height(160.0).child(
Element::new()
.id(target_id)
.height(40.0)
.child(Element::text("offscreen target", text_style())),
),
),
),
);
let snapshot = layout_snapshot(1, UiSize::new(400.0, 120.0), &root);
assert!(
snapshot
.scene
.items
.iter()
.all(|item| !matches!(item, DisplayItem::Text(text) if text.text.contains("offscreen target"))),
"offscreen target text should still be scene-culled"
);
assert!(
snapshot
.interaction_tree
.rect_for_element(target_id)
.is_some(),
"offscreen descendants must keep geometry for widget-ref scrolling"
);
}
}

View File

@@ -36,7 +36,7 @@ pub use layout::{
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
PlatformRuntime, start_headless,
PlatformRuntime, set_command_wake_hook, start_headless,
};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{

View File

@@ -4,8 +4,8 @@ use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fmt;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
@@ -18,6 +18,13 @@ use crate::trace_targets;
use crate::tree::CursorIcon;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
type CommandWakeHook = Arc<dyn Fn() + Send + Sync + 'static>;
fn command_wake_hook() -> &'static Mutex<Option<CommandWakeHook>> {
static HOOK: OnceLock<Mutex<Option<CommandWakeHook>>> = OnceLock::new();
HOOK.get_or_init(|| Mutex::new(None))
}
#[derive(Clone)]
pub struct PlatformProxy {
command_tx: mpsc::UnboundedSender<PlatformRequest>,
@@ -346,7 +353,20 @@ impl PlatformProxy {
}
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> {
self.command_tx.send(command).map_err(|_| PlatformClosed)
self.command_tx.send(command).map_err(|_| PlatformClosed)?;
if let Ok(hook) = command_wake_hook().lock()
&& let Some(hook) = hook.as_ref().cloned()
{
hook();
}
Ok(())
}
}
#[doc(hidden)]
pub fn set_command_wake_hook(hook: Option<CommandWakeHook>) {
if let Ok(mut slot) = command_wake_hook().lock() {
*slot = hook;
}
}