initial macos porting work

This commit is contained in:
Will Temple
2026-03-23 15:16:20 -04:00
parent 4193457fc4
commit 861bf63621
40 changed files with 4575 additions and 233 deletions

View File

@@ -67,7 +67,9 @@ fn bench_scroll_list(c: &mut Criterion) {
let viewport_height = 640.0;
let n = 500;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
let mut scroll_box = Element::scroll_box(0.0)
.width(400.0)
.height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
@@ -90,5 +92,10 @@ fn bench_scroll_list(c: &mut Criterion) {
});
}
criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list);
criterion_group!(
benches,
bench_static_tree,
bench_single_change,
bench_scroll_list
);
criterion_main!(benches);

View File

@@ -107,6 +107,7 @@ fn log_platform_event(event: &PlatformEvent) {
"clipboard text received"
);
}
#[cfg(target_os = "linux")]
PlatformEvent::PrimarySelectionText { window_id, text } => {
tracing::debug!(
event = "primary_selection_text",

View File

@@ -128,7 +128,12 @@ impl LayoutNode {
/// Return a copy of this node (and all descendants) with all positions shifted by `offset`.
pub fn translated(self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
Rect::new(
r.origin.x + o.x,
r.origin.y + o.y,
r.size.width,
r.size.height,
)
}
let rect = translate_rect(self.rect, offset);
let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset));
@@ -143,7 +148,11 @@ impl LayoutNode {
rect: translate_rect(img.rect, offset),
..img
});
let children = self.children.into_iter().map(|c| c.translated(offset)).collect();
let children = self
.children
.into_iter()
.map(|c| c.translated(offset))
.collect();
LayoutNode {
rect,
clip_rect,
@@ -241,6 +250,7 @@ pub fn layout_snapshot_with_cache(
}
}
#[allow(clippy::too_many_arguments)]
fn layout_element(
element: &Element,
rect: Rect,
@@ -256,24 +266,24 @@ fn layout_element(
perf_stats.nodes += 1;
// Viewport culling: skip fully off-screen elements inside a scroll box.
if let Some(clip) = clip_rect {
if !rects_overlap(rect, clip) {
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
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(),
};
}
if let Some(clip) = clip_rect
&& !rects_overlap(rect, clip)
{
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
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(),
};
}
// Incremental layout cache check.
@@ -368,7 +378,13 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
@@ -382,7 +398,13 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
@@ -508,7 +530,13 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
@@ -518,7 +546,13 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
@@ -527,7 +561,13 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
interaction.children = layout_container_children(
@@ -545,7 +585,13 @@ fn layout_element(
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
interaction
}
@@ -558,10 +604,13 @@ fn cache_layout(
origin: Point,
) {
let neg = Point::new(-origin.x, -origin.y);
cache.results.insert(key, CachedLayout {
interaction_node: interaction.clone().translated(neg),
scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
});
cache.results.insert(
key,
CachedLayout {
interaction_node: interaction.clone().translated(neg),
scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
},
);
}
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
@@ -735,6 +784,7 @@ struct MeasuredChild {
is_flex: bool,
}
#[allow(clippy::too_many_arguments)]
fn layout_container_children(
element: &Element,
content: Rect,
@@ -986,7 +1036,14 @@ fn intrinsic_size(
return cached;
}
let insets = content_insets(&element.style);
let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache);
let result = intrinsic_size_inner(
element,
available_size,
insets,
text_system,
perf_stats,
layout_cache,
);
layout_cache.intrinsic_cache.insert(cache_key, result);
result
}
@@ -1047,8 +1104,13 @@ fn intrinsic_size_inner(
);
}
let intrinsic_content =
intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache);
let intrinsic_content = intrinsic_container_content_size(
element,
content_size,
text_system,
perf_stats,
layout_cache,
);
UiSize::new(
explicit_width
@@ -1539,9 +1601,9 @@ fn child_rect(
mod tests {
use super::{LayoutCache, 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};
use crate::tree::{Edges, Element, ElementId, FlexDirection};
use crate::text::TextSystem;
#[test]
fn row_layout_apportions_fixed_and_flex_children() {
@@ -2156,10 +2218,12 @@ mod tests {
let size = UiSize::new(400.0, 600.0);
let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache);
assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical");
assert_eq!(
snap1.interaction_tree.root,
snap2.interaction_tree.root,
snap1.scene.items, snap2.scene.items,
"scene items should be identical"
);
assert_eq!(
snap1.interaction_tree.root, snap2.interaction_tree.root,
"interaction trees should be identical"
);
}
@@ -2198,12 +2262,14 @@ mod tests {
let item_height = 40.0;
let viewport_height = 200.0;
let n = 100;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
let mut scroll_box = Element::scroll_box(0.0)
.width(400.0)
.height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
.height(item_height)
.child(Element::text(format!("item {i}"), text_style()))
.child(Element::text(format!("item {i}"), text_style())),
);
}
let root = Element::new().child(scroll_box);
@@ -2214,11 +2280,20 @@ mod tests {
let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
// Only text items within the 200px viewport should appear in the scene.
let text_items = snap.scene.items.iter().filter(|item| {
matches!(item, DisplayItem::Text(_))
}).count();
let text_items = snap
.scene
.items
.iter()
.filter(|item| matches!(item, DisplayItem::Text(_)))
.count();
// At most 6 text items should be visible (5 fit + 1 partial).
assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)");
assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}");
assert!(
text_items <= 6,
"expected <= 6 text items in scene, got {text_items} (culling not working)"
);
assert!(
text_items >= 4,
"expected >= 4 text items visible, got {text_items}"
);
}
}

View File

@@ -8,6 +8,7 @@
pub(crate) mod trace_targets {
pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene";
#[allow(dead_code)]
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
}

View File

@@ -32,7 +32,7 @@ pub struct PlatformProxy {
pub struct PlatformRuntime {
proxy: PlatformProxy,
events: mpsc::Receiver<PlatformEvent>,
_worker: WorkerHandle,
worker: Option<WorkerHandle>,
}
pub struct PlatformEndpoint {
@@ -73,6 +73,7 @@ pub enum PlatformEvent {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
PrimarySelectionText {
window_id: WindowId,
text: String,
@@ -118,10 +119,12 @@ pub enum PlatformRequest {
RequestClipboardText {
window_id: WindowId,
},
#[cfg(target_os = "linux")]
SetPrimarySelectionText {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
RequestPrimarySelectionText {
window_id: WindowId,
},
@@ -203,7 +206,30 @@ impl PlatformRuntime {
Self {
proxy,
events: event_rx,
_worker: worker,
worker: Some(worker),
}
}
/// Creates a platform runtime hosted by the current runtime thread.
///
/// This is useful for backends that must run on a specific host thread
/// (for example, AppKit on macOS main thread).
pub fn custom_local(start: impl FnOnce(PlatformEndpoint)) -> 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)),
};
start(PlatformEndpoint {
commands: command_rx,
events: event_tx,
});
Self {
proxy,
events: event_rx,
worker: None,
}
}
@@ -216,6 +242,7 @@ impl PlatformRuntime {
}
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
let _ = self.worker.as_ref();
let mut events = Vec::new();
while let Ok(event) = self.events.try_recv() {
events.push(event);
@@ -247,6 +274,7 @@ impl PlatformProxy {
self.send(PlatformRequest::ReplaceScene { window_id, scene })
}
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
window_id: WindowId,
@@ -258,6 +286,7 @@ impl PlatformProxy {
})
}
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(
&self,
window_id: WindowId,
@@ -356,7 +385,9 @@ pub fn start_headless() -> PlatformRuntime {
}
PlatformRequest::SetClipboardText { .. } => {}
PlatformRequest::RequestClipboardText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::SetPrimarySelectionText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::RequestPrimarySelectionText { .. } => {}
PlatformRequest::SetCursorIcon { .. } => {}
PlatformRequest::EmitCloseRequested { window_id } => {

View File

@@ -110,6 +110,7 @@ impl WindowController {
}
/// Copies plain text to the platform primary-selection buffer for this window.
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
text: impl Into<String>,
@@ -128,6 +129,7 @@ impl WindowController {
}
/// Requests the current plain-text primary selection contents from the platform.
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> {
self.proxy.request_primary_selection_text(self.id)
}

View File

@@ -219,6 +219,7 @@ impl Deref for PreparedText {
impl PreparedText {
/// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_layout(
element_id: Option<ElementId>,
text: String,
@@ -623,10 +624,18 @@ impl DisplayItem {
/// Return a copy of this item with all positions shifted by `offset`.
pub fn translated(&self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
Rect::new(
r.origin.x + o.x,
r.origin.y + o.y,
r.size.width,
r.size.height,
)
}
match self {
Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }),
Self::Quad(q) => Self::Quad(Quad {
rect: translate_rect(q.rect, offset),
..*q
}),
Self::RoundedRect(r) => Self::RoundedRect(RoundedRect {
rect: translate_rect(r.rect, offset),
..*r
@@ -838,8 +847,8 @@ mod tests {
#[test]
fn prepared_text_vertical_offset_moves_between_lines() {
use std::sync::Arc;
use super::{PreparedTextLine, TextLayoutData};
use std::sync::Arc;
let mut text = PreparedText::monospace(
"abcdwxyz",
@@ -873,7 +882,11 @@ mod tests {
}
}
let orig_size = text.layout.size;
text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size });
text.layout = Arc::new(TextLayoutData {
lines,
glyphs,
size: orig_size,
});
assert_eq!(text.vertical_offset(2, 1), Some(6));
assert_eq!(text.vertical_offset(6, -1), Some(2));
@@ -892,8 +905,12 @@ mod tests {
let target_line = &text.lines[1];
// Lines store LOCAL coords; add text.origin to get absolute window coords for the query.
let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5;
let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y));
let start =
text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(
text.origin.x + target_line.rect.origin.x + 16.0,
y,
));
let rects = text.selection_rects(start, end);
assert_eq!(rects.len(), 1);

View File

@@ -10,8 +10,9 @@ use cosmic_text::{
};
use fontconfig::Fontconfig;
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData,
UiSize};
use crate::{
Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, UiSize,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextAlign {
@@ -443,7 +444,11 @@ impl TextSystem {
// For no-wrap + start-aligned text, width doesn't affect shaping.
// Pass None so cosmic-text doesn't truncate and the result is
// consistent with the width-independent cache key.
let effective_width = if width_affects_layout(style) { width } else { None };
let effective_width = if width_affects_layout(style) {
width
} else {
None
};
borrowed.set_size(effective_width, None);
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
if uses_plain_text_fast_path {
@@ -495,9 +500,7 @@ impl TextSystem {
// is color-independent. Per-span colors (from glyph.color_opt)
// are stored directly since they are part of the shape key via
// the spans hash.
color: glyph
.color_opt
.map_or(Color::SENTINEL, color_from_cosmic),
color: glyph.color_opt.map_or(Color::SENTINEL, color_from_cosmic),
cache_key: Some(physical.cache_key),
text_start: line_offset + glyph.start,
text_end: line_offset + glyph.end,
@@ -696,16 +699,16 @@ fn attrs_for_span<'a>(
base
};
base.weight(match span.weight {
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
TextSpanWeight::Bold => CosmicWeight::BOLD,
})
.style(match span.slant {
TextSpanSlant::Normal => CosmicStyle::Normal,
TextSpanSlant::Italic => CosmicStyle::Italic,
TextSpanSlant::Oblique => CosmicStyle::Oblique,
})
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
TextSpanWeight::Bold => CosmicWeight::BOLD,
})
.style(match span.slant {
TextSpanSlant::Normal => CosmicStyle::Normal,
TextSpanSlant::Italic => CosmicStyle::Italic,
TextSpanSlant::Oblique => CosmicStyle::Oblique,
})
}
fn to_cosmic_color(color: Color) -> CosmicColor {
@@ -930,7 +933,9 @@ impl std::hash::Hash for TextStyle {
self.line_height.to_bits().hash(state);
self.color.hash(state);
self.font_family.hash(state);
self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state);
self.bounds
.map(|b| (b.width.to_bits(), b.height.to_bits()))
.hash(state);
self.wrap.hash(state);
self.align.hash(state);
self.max_lines.hash(state);
@@ -1038,16 +1043,32 @@ mod tests {
let blue = text_system.prepare("hello", origin, &style_blue);
// Both PreparedTexts must have identical glyph shapes and sentinel colors —
// the second call hits the shape cache and produces the same layout data.
assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match");
assert_eq!(
red.glyphs.len(),
blue.glyphs.len(),
"glyph count must match"
);
for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) {
assert_eq!(r.position, b.position, "glyph positions must match");
assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match");
assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(
r.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
assert_eq!(
b.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
}
// PreparedText::clone() is an Arc clone — O(1).
let cloned = red.clone();
assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc");
assert_eq!(
cloned.layout_ptr(),
red.layout_ptr(),
"clone must share the same Arc"
);
// But they should carry different default colors.
assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF));