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

43
Cargo.lock generated
View File

@@ -1314,6 +1314,32 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -1333,6 +1359,17 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-metal" name = "objc2-metal"
version = "0.3.2" version = "0.3.2"
@@ -1352,8 +1389,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-metal", "objc2-metal",
] ]
@@ -1863,7 +1904,9 @@ dependencies = [
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"raw-window-metal",
"ruin-runtime", "ruin-runtime",
"ruin_ui", "ruin_ui",
"ruin_ui_renderer_wgpu", "ruin_ui_renderer_wgpu",

View File

@@ -282,6 +282,14 @@ impl Reactor {
self.inner.ensure_flush_scheduled(); self.inner.ensure_flush_scheduled();
} }
/// Flushes currently queued reactive jobs immediately on the calling thread.
///
/// This is useful when host integrations need synchronous propagation
/// (for example, during native resize loops).
pub fn flush_now(&self) {
Rc::clone(&self.inner).flush_jobs();
}
pub(crate) fn allocate_node(&self) -> NodeId { pub(crate) fn allocate_node(&self) -> NodeId {
let raw = self.inner.next_node.get(); let raw = self.inner.next_node.get();
self.inner.next_node.set(raw.wrapping_add(1)); self.inner.next_node.set(raw.wrapping_add(1));

18
lib/ruin_app/build.rs Normal file
View File

@@ -0,0 +1,18 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=macos/Info.plist");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "macos" {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir"));
let plist = manifest_dir.join("macos/Info.plist");
let arg = format!("-Wl,-sectcreate,__TEXT,__info_plist,{}", plist.display());
// Ensure all ruin_app executables/examples opt into Retina rendering.
println!("cargo:rustc-link-arg-examples={arg}");
}

View File

@@ -1,7 +1,16 @@
use ruin_app::prelude::*; use ruin_app::prelude::*;
use tracing_subscriber::EnvFilter;
#[ruin_runtime::async_main] #[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> { async fn main() -> ruin_app::Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(true)
.without_time()
.try_init();
let title = "RUIN Counter"; let title = "RUIN Counter";
App::new() App::new()

View File

@@ -90,21 +90,12 @@ fn TaskBoard() -> impl IntoView {
} }
}); });
let all_label = if matches!(filter.get(), TaskFilter::All) { let all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All");
"● All" let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open");
} else { let done_label = filter_label(
"○ All" matches!(filter.get(), TaskFilter::CompletedOnly),
}; "Completed",
let open_label = if matches!(filter.get(), TaskFilter::OpenOnly) { );
"● Open"
} else {
"○ Open"
};
let done_label = if matches!(filter.get(), TaskFilter::CompletedOnly) {
"● Completed"
} else {
"○ Completed"
};
let task_rows = visible_ids.with(|ids| { let task_rows = visible_ids.with(|ids| {
ids.iter() ids.iter()
.map(|task_id| { .map(|task_id| {
@@ -466,6 +457,14 @@ fn move_task(tasks: &Signal<Vec<Task>>, task_id: u64, direction: MoveDirection)
}); });
} }
fn filter_label(active: bool, label: &str) -> String {
if active {
format!("[x] {label}")
} else {
format!("[ ] {label}")
}
}
fn seed_tasks() -> Vec<Task> { fn seed_tasks() -> Vec<Task> {
vec![ vec![
Task { Task {

View File

@@ -268,6 +268,7 @@ fn RuntimeIoExample(server_addr: SocketAddr) -> impl IntoView {
}; };
eprintln!("example05: {message}"); eprintln!("example05: {message}");
let _ = save_status.set(Some(message)); let _ = save_status.set(Some(message));
flush_reactive_updates();
} }
})); }));
} }

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>RUIN</string>
<key>CFBundleIdentifier</key>
<string>dev.ruin.example</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

View File

@@ -15,8 +15,8 @@ use std::marker::PhantomData;
use std::rc::Rc; use std::rc::Rc;
use std::time::Instant; use std::time::Instant;
use ruin_reactivity::effect; use ruin_reactivity::{current as current_reactor, effect};
use ruin_runtime::queue_future; use ruin_runtime::{queue_future, spawn_worker};
use ruin_ui::{ use ruin_ui::{
Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree,
KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent,
@@ -26,7 +26,7 @@ use ruin_ui::{
layout_snapshot_with_cache, layout_snapshot_with_cache,
}; };
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use ruin_ui_platform_macos::start_macos_ui; use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use ruin_ui_platform_wayland::start_wayland_ui; use ruin_ui_platform_wayland::start_wayland_ui;
@@ -98,8 +98,7 @@ impl App {
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> { pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp { MountedApp {
window: self.window, window: self.window,
root: Rc::new(root), root,
render_state: Rc::new(RenderState::default()),
} }
} }
} }
@@ -150,19 +149,18 @@ impl Mountable for Element {
pub struct MountedApp<M: Mountable> { pub struct MountedApp<M: Mountable> {
window: Window, window: Window,
root: Rc<M>, root: M,
render_state: Rc<RenderState>,
} }
impl<M: Mountable> MountedApp<M> { impl<M: Mountable> MountedApp<M> {
pub async fn run(self) -> Result<()> { async fn run_with_ui_runtime(
let MountedApp { window: Window,
window: app_window, root: M,
root, mut ui: ruin_ui::UiRuntime,
render_state, ) -> Result<()> {
} = self; let root = Rc::new(root);
let render_state = Rc::new(RenderState::default());
let mut ui = start_platform_ui(); let app_window = window;
let window = ui.create_window(app_window.spec.clone())?; let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window let initial_viewport = app_window
.spec .spec
@@ -170,6 +168,7 @@ impl<M: Mountable> MountedApp<M> {
.unwrap_or(UiSize::new(960.0, 640.0)); .unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport); let viewport = ruin_reactivity::cell(initial_viewport);
let configure_serial = ruin_reactivity::cell(0_u64);
let scene_version = StdCell::new(0_u64); let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new())); let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new())); let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
@@ -183,6 +182,7 @@ impl<M: Mountable> MountedApp<M> {
let _scene_effect = effect({ let _scene_effect = effect({
let window = window.clone(); let window = window.clone();
let viewport = viewport.clone(); let viewport = viewport.clone();
let configure_serial = configure_serial.clone();
let text_system = Rc::clone(&text_system); let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache); let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree); let interaction_tree = Rc::clone(&interaction_tree);
@@ -194,6 +194,7 @@ impl<M: Mountable> MountedApp<M> {
let text_selection = Rc::clone(&input_state.text_selection); let text_selection = Rc::clone(&input_state.text_selection);
move || { move || {
let viewport = viewport.get(); let viewport = viewport.get();
let _ = configure_serial.get();
let version = scene_version.get().wrapping_add(1); let version = scene_version.get().wrapping_add(1);
scene_version.set(version); scene_version.set(version);
let _ = text_selection.version.get(); let _ = text_selection.version.get();
@@ -266,6 +267,8 @@ impl<M: Mountable> MountedApp<M> {
"app received Configured, queuing layout effect" "app received Configured, queuing layout effect"
); );
let _ = viewport.set(configuration.actual_inner_size); let _ = viewport.set(configuration.actual_inner_size);
configure_serial.update(|value| *value = value.wrapping_add(1));
current_reactor().flush_now();
} }
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event( Self::handle_pointer_event(
@@ -276,6 +279,7 @@ impl<M: Mountable> MountedApp<M> {
&mut input_state, &mut input_state,
event, event,
)?; )?;
current_reactor().flush_now();
} }
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event( Self::handle_keyboard_event(
@@ -285,11 +289,22 @@ impl<M: Mountable> MountedApp<M> {
&input_state, &input_state,
event, event,
)?; )?;
current_reactor().flush_now();
} }
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => { PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received CloseRequested, issuing open(false)"
);
let _ = window.update(WindowUpdate::new().open(false)); let _ = window.update(WindowUpdate::new().open(false));
} }
PlatformEvent::Closed { window_id } if window_id == window.id() => { PlatformEvent::Closed { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received Closed, shutting down ui runtime"
);
ui.shutdown()?; ui.shutdown()?;
return Ok(()); return Ok(());
} }
@@ -301,6 +316,14 @@ impl<M: Mountable> MountedApp<M> {
ui.shutdown()?; ui.shutdown()?;
Ok(()) Ok(())
} }
}
impl<M: Mountable> MountedApp<M> {
#[cfg(not(target_os = "macos"))]
pub async fn run(self) -> Result<()> {
let ui = start_platform_ui();
Self::run_with_ui_runtime(self.window, self.root, ui).await
}
fn handle_pointer_event( fn handle_pointer_event(
window: &WindowController, window: &WindowController,
@@ -453,13 +476,46 @@ impl<M: Mountable> MountedApp<M> {
if selection_changed { if selection_changed {
text_selection.version.update(|value| *value += 1); text_selection.version.update(|value| *value += 1);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
sync_primary_selection(_window, interaction_tree, *text_selection.selection.borrow())?; sync_primary_selection(
_window,
interaction_tree,
*text_selection.selection.borrow(),
)?;
} }
Ok(()) Ok(())
} }
} }
#[cfg(target_os = "macos")]
impl<M: Mountable + Send + 'static> MountedApp<M> {
pub async fn run(self) -> Result<()> {
let ui = start_platform_ui();
let (done_tx, done_rx) = std::sync::mpsc::channel::<std::result::Result<(), String>>();
let window = self.window;
let root = self.root;
let _worker = spawn_worker(
move || {
queue_future(async move {
let result = Self::run_with_ui_runtime(window, root, ui)
.await
.map_err(|err| err.to_string());
let _ = done_tx.send(result);
});
},
|| {},
);
run_macos_app_event_loop();
match done_rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err.into()),
Err(_) => Err("ui worker terminated before completion".into()),
}
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct View { pub struct View {
element: Element, element: Element,
@@ -1307,6 +1363,10 @@ pub fn use_effect(effect: impl Fn() + 'static) {
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ()); with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
} }
pub fn flush_reactive_updates() {
current_reactor().flush_now();
}
pub fn use_window_title(compute: impl FnOnce() -> String) { pub fn use_window_title(compute: impl FnOnce() -> String) {
with_render_context_state(|context| { with_render_context_state(|context| {
context.side_effects.borrow_mut().window_title = Some(compute()); context.side_effects.borrow_mut().window_title = Some(compute());
@@ -1520,6 +1580,7 @@ where
let result = future.await; let result = future.await;
if generation.get() == next_generation { if generation.get() == next_generation {
let _ = resource.state.replace(ResourceState::Ready(result)); let _ = resource.state.replace(ResourceState::Ready(result));
current_reactor().flush_now();
} }
})); }));
} }
@@ -1997,10 +2058,10 @@ pub mod prelude {
ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable,
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
WidgetRef, Window, block, button, colors, column, component, context_provider, provide, WidgetRef, Window, block, button, colors, column, component, context_provider,
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, flush_reactive_updates, provide, row, scroll_box, surfaces, text, use_context, use_effect,
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, use_memo, use_resource, use_shortcut, use_shortcut_with_context, use_signal,
view, use_widget_ref, use_window_title, view,
}; };
pub use ruin_ui::{ pub use ruin_ui::{
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,

View File

@@ -77,7 +77,7 @@ pub use platform::current::driver::{
pub use platform::current::runtime::{ pub use platform::current::runtime::{
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval, IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run, clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run,
set_interval, set_timeout, spawn_worker, yield_now, run_ready_tasks, run_until_stalled, set_interval, set_timeout, spawn_worker, yield_now,
}; };
/// Public mesh-allocator surface re-exported from the Linux x86_64 backend. /// Public mesh-allocator surface re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[cfg(all(target_os = "linux", target_arch = "x86_64"))]

View File

@@ -478,6 +478,99 @@ pub fn run() {
} }
} }
/// Drains ready work on the current runtime thread without blocking for future work.
///
/// Unlike [`run`], this returns as soon as there are no immediately runnable
/// microtasks or macrotasks left. It is intended for host integrations that
/// need to re-enter the scheduler while an outer platform loop remains active.
pub fn run_until_stalled() {
let _ = current_thread();
loop {
drain_all();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_all();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_all();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
/// Drains already-queued work on the current runtime thread without polling the
/// driver for timers or I/O readiness.
///
/// This is intended for host integrations that need to flush application work
/// from inside a host callback without re-entering timer callbacks.
pub fn run_ready_tasks() {
let _ = current_thread();
loop {
drain_remote_tasks();
drain_completed_workers();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_remote_tasks();
drain_completed_workers();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_remote_tasks();
drain_completed_workers();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
fn drain_all() { fn drain_all() {
drain_driver_events(); drain_driver_events();
drain_remote_tasks(); drain_remote_tasks();

View File

@@ -480,6 +480,99 @@ pub fn run() {
} }
} }
/// Drains ready work on the current runtime thread without blocking for future work.
///
/// Unlike [`run`], this returns as soon as there are no immediately runnable
/// microtasks or macrotasks left. It is intended for host integrations that
/// need to re-enter the scheduler while an outer platform loop remains active.
pub fn run_until_stalled() {
let _ = current_thread();
loop {
drain_all();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_all();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_all();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
/// Drains already-queued work on the current runtime thread without polling the
/// driver for timers or I/O readiness.
///
/// This is intended for host integrations that need to flush application work
/// from inside a host callback without re-entering timer callbacks.
pub fn run_ready_tasks() {
let _ = current_thread();
loop {
drain_remote_tasks();
drain_completed_workers();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_remote_tasks();
drain_completed_workers();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_remote_tasks();
drain_completed_workers();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
fn drain_all() { fn drain_all() {
drain_driver_events(); drain_driver_events();
drain_remote_tasks(); drain_remote_tasks();

View File

@@ -270,20 +270,14 @@ fn layout_element(
&& !rects_overlap(rect, clip) && !rects_overlap(rect, clip)
{ {
perf_stats.viewport_culled += 1; perf_stats.viewport_culled += 1;
return LayoutNode { return layout_interaction_skeleton(
path, element,
element_id: element.id,
rect, rect,
corner_radius: 0.0, path,
clip_rect: None, text_system,
pointer_events: element.style.pointer_events, perf_stats,
focusable: element.style.focusable, layout_cache,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default), );
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
} }
// Incremental layout cache check. // Incremental layout cache check.
@@ -295,6 +289,7 @@ fn layout_element(
subtree_hash, subtree_hash,
avail_width_bits: rect.size.width.round() as u32, avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.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) { if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin; let offset = rect.origin;
@@ -888,6 +883,210 @@ fn layout_container_children(
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( fn intrinsic_main_size(
child: &Element, child: &Element,
direction: FlexDirection, direction: FlexDirection,
@@ -1147,6 +1346,7 @@ struct LayoutCacheKey {
subtree_hash: u64, subtree_hash: u64,
avail_width_bits: u32, avail_width_bits: u32,
avail_height_bits: u32, avail_height_bits: u32,
clip_rect_bits: Option<(u32, u32, u32, u32)>,
} }
struct CachedLayout { struct CachedLayout {
@@ -1208,6 +1408,16 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
&& a.origin.y + a.size.height > b.origin.y && 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 { fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size(); let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 { let source_aspect = if source_size.height > 0.0 {
@@ -1599,7 +1809,9 @@ fn child_rect(
#[cfg(test)] #[cfg(test)]
mod tests { 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::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem; use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap}; use crate::text::{TextStyle, TextWrap};
@@ -2254,7 +2466,6 @@ mod tests {
#[test] #[test]
fn scroll_culling_skips_off_screen_children() { fn scroll_culling_skips_off_screen_children() {
use crate::tree::ScrollbarStyle;
let mut text_system = TextSystem::new(); let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new(); let mut layout_cache = LayoutCache::new();
@@ -2296,4 +2507,108 @@ mod tests {
"expected >= 4 text items visible, got {text_items}" "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 layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{ pub use platform::{
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest, PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
PlatformRuntime, start_headless, PlatformRuntime, set_command_wake_hook, start_headless,
}; };
pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{ pub use scene::{

View File

@@ -4,8 +4,8 @@ use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use ruin_runtime::channel::mpsc; use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker}; use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
@@ -18,6 +18,13 @@ use crate::trace_targets;
use crate::tree::CursorIcon; use crate::tree::CursorIcon;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate}; 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)] #[derive(Clone)]
pub struct PlatformProxy { pub struct PlatformProxy {
command_tx: mpsc::UnboundedSender<PlatformRequest>, command_tx: mpsc::UnboundedSender<PlatformRequest>,
@@ -346,7 +353,20 @@ impl PlatformProxy {
} }
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> { 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;
} }
} }

View File

@@ -8,7 +8,9 @@ libc = "0.2"
objc2 = "0.6.4" objc2 = "0.6.4"
objc2-core-foundation = "0.3.2" objc2-core-foundation = "0.3.2"
objc2-foundation = "0.3.2" objc2-foundation = "0.3.2"
objc2-quartz-core = "0.3.2"
raw-window-handle = "0.6" raw-window-handle = "0.6"
raw-window-metal = "1.1.0"
ruin_ui = { path = "../ui" } ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" } ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" }

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,8 @@ struct PreparedGlyphBitmap {
struct RasterizedText { struct RasterizedText {
origin_offset: Point, origin_offset: Point,
size: UiSize, draw_size: UiSize,
texture_size: UiSize,
pixels: Vec<u8>, pixels: Vec<u8>,
} }
@@ -170,6 +171,7 @@ struct UploadedAtlasText {
struct TextTextureKey { struct TextTextureKey {
text: String, text: String,
bounds: Option<(u32, u32)>, bounds: Option<(u32, u32)>,
clip: Option<(u32, u32, u32, u32)>,
font_size_bits: u32, font_size_bits: u32,
line_height_bits: u32, line_height_bits: u32,
color: (u8, u8, u8, u8), color: (u8, u8, u8, u8),
@@ -330,6 +332,7 @@ pub struct WgpuSceneRenderer {
image_cache_order: VecDeque<u64>, image_cache_order: VecDeque<u64>,
#[allow(dead_code)] #[allow(dead_code)]
glyph_atlas: GlyphAtlas, glyph_atlas: GlyphAtlas,
scale_factor: f32,
} }
const MAX_TEXT_CACHE_ENTRIES: usize = 64; const MAX_TEXT_CACHE_ENTRIES: usize = 64;
@@ -366,8 +369,8 @@ impl WgpuSceneRenderer {
format, format,
width: width.max(1), width: width.max(1),
height: height.max(1), height: height.max(1),
present_mode: wgpu::PresentMode::AutoVsync, present_mode: wgpu::PresentMode::AutoNoVsync,
desired_maximum_frame_latency: 2, desired_maximum_frame_latency: 1,
alpha_mode: caps.alpha_modes[0], alpha_mode: caps.alpha_modes[0],
view_formats: vec![], view_formats: vec![],
}; };
@@ -710,6 +713,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
image_cache: HashMap::new(), image_cache: HashMap::new(),
image_cache_order: VecDeque::new(), image_cache_order: VecDeque::new(),
glyph_atlas, glyph_atlas,
scale_factor: 1.0,
}) })
} }
@@ -723,9 +727,33 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
} }
pub fn set_scale_factor(&mut self, scale_factor: f32) {
let next = scale_factor.max(1.0);
if (self.scale_factor - next).abs() <= f32::EPSILON {
return;
}
self.scale_factor = next;
// Text cache is scale-dependent; flush on scale transitions (e.g. display move).
self.text_cache.clear();
self.text_cache_order.clear();
self.glyph_atlas = create_glyph_atlas(
&self.device,
&self.text_bind_group_layout,
&self.text_sampler,
);
}
pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> { pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> {
self.render_with_logical_size(scene, scene.logical_size)
}
pub fn render_with_logical_size(
&mut self,
scene: &SceneSnapshot,
logical_size: UiSize,
) -> Result<(), RenderError> {
let render_start = std::time::Instant::now(); let render_start = std::time::Instant::now();
let vertices = build_vertices(scene); let vertices = build_vertices(scene, logical_size);
let frame = match self.surface.get_current_texture() { let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame) wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
@@ -749,7 +777,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let mut uploaded_images = Vec::new(); let mut uploaded_images = Vec::new();
let mut uploaded_texts = Vec::new(); let mut uploaded_texts = Vec::new();
let mut atlas_perf = AtlasTextPerfStats::default(); let mut atlas_perf = AtlasTextPerfStats::default();
let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene, &mut atlas_perf); let uploaded_atlas_text =
self.prepare_uploaded_atlas_text(scene, logical_size, &mut atlas_perf);
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
for item in &scene.items { for item in &scene.items {
@@ -760,17 +789,19 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip),
DisplayItem::Image(image) => { DisplayItem::Image(image) => {
if let Some(uploaded) = if let Some(uploaded) =
self.prepare_uploaded_image(image, scene.logical_size, active_clip) self.prepare_uploaded_image(image, logical_size, active_clip)
{ {
uploaded_images.push(uploaded); uploaded_images.push(uploaded);
} }
} }
DisplayItem::Text(text) => { DisplayItem::Text(text) => {
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { if (self.scale_factor - 1.0).abs() <= f32::EPSILON
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
{
continue; continue;
} }
if let Some(uploaded) = if let Some(uploaded) =
self.prepare_uploaded_text(text, scene.logical_size, active_clip) self.prepare_uploaded_text(text, logical_size, active_clip)
{ {
uploaded_texts.push(uploaded); uploaded_texts.push(uploaded);
} }
@@ -854,21 +885,29 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
Ok(()) Ok(())
} }
fn rasterize_text(&mut self, text: &PreparedText) -> Option<RasterizedText> { fn rasterize_text(
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { &mut self,
return self.rasterize_prepared_text(text); text: &PreparedText,
raster_clip: Option<Rect>,
) -> Option<RasterizedText> {
if (self.scale_factor - 1.0).abs() <= f32::EPSILON
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
{
return self.rasterize_prepared_text(text, raster_clip);
} }
let glyphs = self.collect_glyph_bitmaps(text); let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
let clip = match text.bounds { let glyphs = self.collect_glyph_bitmaps(text, clip);
Some(bounds) => PixelRect { let clip = clip
.or_else(|| {
text.bounds.map(|bounds| PixelRect {
left: 0, left: 0,
top: 0, top: 0,
right: bounds.width.ceil() as i32, right: (bounds.width * self.scale_factor).ceil() as i32,
bottom: bounds.height.ceil() as i32, bottom: (bounds.height * self.scale_factor).ceil() as i32,
}, })
None => glyph_union(&glyphs)?, })
}; .or_else(|| glyph_union(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 { if clip.width() == 0 || clip.height() == 0 {
return None; return None;
} }
@@ -879,23 +918,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
} }
Some(RasterizedText { Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32), origin_offset: Point::new(
size: UiSize::new(clip.width() as f32, clip.height() as f32), clip.left as f32 / self.scale_factor,
clip.top as f32 / self.scale_factor,
),
draw_size: UiSize::new(
clip.width() as f32 / self.scale_factor,
clip.height() as f32 / self.scale_factor,
),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels, pixels,
}) })
} }
fn rasterize_prepared_text(&mut self, text: &PreparedText) -> Option<RasterizedText> { fn rasterize_prepared_text(
&mut self,
text: &PreparedText,
raster_clip: Option<Rect>,
) -> Option<RasterizedText> {
let glyphs = self.collect_prepared_glyphs(text); let glyphs = self.collect_prepared_glyphs(text);
let clip = match text.bounds { let clip = raster_clip
Some(bounds) => PixelRect { .map(rect_to_pixel_rect)
.or_else(|| {
text.bounds.map(|bounds| PixelRect {
left: 0, left: 0,
top: 0, top: 0,
right: bounds.width.ceil() as i32, right: bounds.width.ceil() as i32,
bottom: bounds.height.ceil() as i32, bottom: bounds.height.ceil() as i32,
}, })
None => glyph_union_prepared(&glyphs)?, })
}; .or_else(|| glyph_union_prepared(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 { if clip.width() == 0 || clip.height() == 0 {
return None; return None;
} }
@@ -914,7 +966,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
Some(RasterizedText { Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32), origin_offset: Point::new(clip.left as f32, clip.top as f32),
size: UiSize::new(clip.width() as f32, clip.height() as f32), draw_size: UiSize::new(clip.width() as f32, clip.height() as f32),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels, pixels,
}) })
} }
@@ -955,13 +1008,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyphs glyphs
} }
fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec<GlyphBitmap> { fn collect_glyph_bitmaps(
let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height)); &mut self,
text: &PreparedText,
raster_clip: Option<PixelRect>,
) -> Vec<GlyphBitmap> {
let mut buffer = Buffer::new_empty(Metrics::new(
text.font_size * self.scale_factor,
text.line_height * self.scale_factor,
));
{ {
let mut borrowed = buffer.borrow_with(&mut self.font_system); let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_size( borrowed.set_size(
text.bounds.map(|bounds| bounds.width), text.bounds.map(|bounds| bounds.width * self.scale_factor),
text.bounds.map(|bounds| bounds.height), text.bounds.map(|bounds| bounds.height * self.scale_factor),
); );
borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None); borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None);
} }
@@ -982,13 +1042,17 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
if width == 0 || height == 0 { if width == 0 || height == 0 {
continue; continue;
} }
glyphs.push(GlyphBitmap { let rect = PixelRect {
rect: PixelRect {
left: physical.x + image.placement.left, left: physical.x + image.placement.left,
top: physical.y - image.placement.top, top: physical.y - image.placement.top,
right: physical.x + image.placement.left + width, right: physical.x + image.placement.left + width,
bottom: physical.y - image.placement.top + height, bottom: physical.y - image.placement.top + height,
}, };
if raster_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
continue;
}
glyphs.push(GlyphBitmap {
rect,
content: image.content, content: image.content,
color: glyph color: glyph
.color_opt .color_opt
@@ -1003,8 +1067,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
fn prepare_uploaded_atlas_text( fn prepare_uploaded_atlas_text(
&mut self, &mut self,
scene: &SceneSnapshot, scene: &SceneSnapshot,
logical_size: UiSize,
perf: &mut AtlasTextPerfStats, perf: &mut AtlasTextPerfStats,
) -> Option<UploadedAtlasText> { ) -> Option<UploadedAtlasText> {
if (self.scale_factor - 1.0).abs() > f32::EPSILON {
return None;
}
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
@@ -1073,7 +1141,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyph_rect, glyph_rect,
atlas_glyph.atlas_rect, atlas_glyph.atlas_rect,
clip_rect, clip_rect,
scene.logical_size, logical_size,
resolved_color, resolved_color,
active_clip, active_clip,
); );
@@ -1246,9 +1314,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
logical_size: UiSize, logical_size: UiSize,
clip: ActiveClip, clip: ActiveClip,
) -> Option<UploadedText> { ) -> Option<UploadedText> {
let key = text_texture_key(text); let raster_clip = text_raster_clip(text, clip, logical_size)?;
let key = text_texture_key(text, raster_clip);
if !self.text_cache.contains_key(&key) { if !self.text_cache.contains_key(&key) {
let rasterized = self.rasterize_text(text)?; let rasterized = self.rasterize_text(text, raster_clip)?;
let cached = self.create_cached_text_texture(&rasterized); let cached = self.create_cached_text_texture(&rasterized);
self.text_cache.insert(key.clone(), cached); self.text_cache.insert(key.clone(), cached);
} }
@@ -1316,8 +1385,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let texture = self.device.create_texture(&wgpu::TextureDescriptor { let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("ruin-ui-renderer-wgpu-texture"), label: Some("ruin-ui-renderer-wgpu-texture"),
size: wgpu::Extent3d { size: wgpu::Extent3d {
width: text.size.width as u32, width: text.texture_size.width as u32,
height: text.size.height as u32, height: text.texture_size.height as u32,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
mip_level_count: 1, mip_level_count: 1,
@@ -1337,12 +1406,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
&text.pixels, &text.pixels,
wgpu::TexelCopyBufferLayout { wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(text.size.width as u32 * 4), bytes_per_row: Some(text.texture_size.width as u32 * 4),
rows_per_image: Some(text.size.height as u32), rows_per_image: Some(text.texture_size.height as u32),
}, },
wgpu::Extent3d { wgpu::Extent3d {
width: text.size.width as u32, width: text.texture_size.width as u32,
height: text.size.height as u32, height: text.texture_size.height as u32,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
); );
@@ -1365,7 +1434,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
_texture: texture, _texture: texture,
bind_group, bind_group,
origin_offset: text.origin_offset, origin_offset: text.origin_offset,
size: text.size, size: text.draw_size,
} }
} }
@@ -1885,9 +1954,9 @@ fn color_to_f32(color: Color) -> [f32; 4] {
] ]
} }
fn build_vertices(scene: &SceneSnapshot) -> Vec<Vertex> { fn build_vertices(scene: &SceneSnapshot, logical_size: UiSize) -> Vec<Vertex> {
let width = scene.logical_size.width.max(1.0); let width = logical_size.width.max(1.0);
let height = scene.logical_size.height.max(1.0); let height = logical_size.height.max(1.0);
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
@@ -2149,6 +2218,22 @@ fn rect_to_pixel_rect(rect: Rect) -> PixelRect {
} }
} }
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
PixelRect {
left: (rect.origin.x * scale).floor() as i32,
top: (rect.origin.y * scale).floor() as i32,
right: ((rect.origin.x + rect.size.width) * scale).ceil() as i32,
bottom: ((rect.origin.y + rect.size.height) * scale).ceil() as i32,
}
}
fn pixel_rects_intersect(first: PixelRect, second: PixelRect) -> bool {
first.left < second.right
&& first.right > second.left
&& first.top < second.bottom
&& first.bottom > second.top
}
fn shadow_blur_extent(blur: f32) -> f32 { fn shadow_blur_extent(blur: f32) -> f32 {
blur.max(0.0) * 2.0 blur.max(0.0) * 2.0
} }
@@ -2208,6 +2293,33 @@ fn intersect_rects(first: Option<Rect>, second: Option<Rect>) -> Option<Rect> {
} }
} }
fn text_raster_clip(
text: &PreparedText,
clip: ActiveClip,
logical_size: UiSize,
) -> Option<Option<Rect>> {
let mut absolute_clip = Some(Rect::new(0.0, 0.0, logical_size.width, logical_size.height));
if clip.rect_active {
absolute_clip = intersect_rects(absolute_clip, Some(clip.resolved_rect()?));
}
if let Some(bounds) = text.bounds {
let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height);
absolute_clip = intersect_rects(absolute_clip, Some(text_bounds));
}
if absolute_clip.is_none() {
return None;
}
Some(absolute_clip.map(|rect| {
Rect::new(
rect.origin.x - text.origin.x,
rect.origin.y - text.origin.y,
rect.size.width,
rect.size.height,
)
}))
}
fn empty_clip_rect() -> Rect { fn empty_clip_rect() -> Rect {
Rect::new(1.0, 1.0, -1.0, -1.0) Rect::new(1.0, 1.0, -1.0, -1.0)
} }
@@ -2330,12 +2442,20 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstan
&text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())] &text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())]
} }
fn text_texture_key(text: &PreparedText) -> TextTextureKey { fn text_texture_key(text: &PreparedText, raster_clip: Option<Rect>) -> TextTextureKey {
TextTextureKey { TextTextureKey {
text: text.text.clone(), text: text.text.clone(),
bounds: text bounds: text
.bounds .bounds
.map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())),
clip: raster_clip.map(|clip| {
(
clip.origin.x.to_bits(),
clip.origin.y.to_bits(),
clip.size.width.to_bits(),
clip.size.height.to_bits(),
)
}),
font_size_bits: text.font_size.to_bits(), font_size_bits: text.font_size.to_bits(),
line_height_bits: text.line_height.to_bits(), line_height_bits: text.line_height.to_bits(),
color: ( color: (
@@ -2365,10 +2485,7 @@ mod tests {
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array, ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
push_clip_state, rounded_clip_arrays, text_texture_key, push_clip_state, rounded_clip_arrays, text_texture_key,
}; };
use ruin_ui::{ use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot,
TextSelectionStyle, UiSize,
};
#[test] #[test]
fn quad_scenes_expand_to_six_vertices_per_quad() { fn quad_scenes_expand_to_six_vertices_per_quad() {
@@ -2389,7 +2506,7 @@ mod tests {
Color::rgb(0xFF, 0xFF, 0xFF), Color::rgb(0xFF, 0xFF, 0xFF),
)); ));
let vertices = build_vertices(&scene); let vertices = build_vertices(&scene, scene.logical_size);
assert_eq!(vertices.len(), 12); assert_eq!(vertices.len(), 12);
} }
@@ -2421,7 +2538,10 @@ mod tests {
Color::rgb(0xEE, 0xEE, 0xEE), Color::rgb(0xEE, 0xEE, 0xEE),
); );
assert_eq!(text_texture_key(&first), text_texture_key(&second)); assert_eq!(
text_texture_key(&first, None),
text_texture_key(&second, None)
);
} }
#[test] #[test]