Functional parity with linux
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user