Functional parity with linux
This commit is contained in:
@@ -282,6 +282,14 @@ impl Reactor {
|
||||
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 {
|
||||
let raw = self.inner.next_node.get();
|
||||
self.inner.next_node.set(raw.wrapping_add(1));
|
||||
|
||||
18
lib/ruin_app/build.rs
Normal file
18
lib/ruin_app/build.rs
Normal 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}");
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
use ruin_app::prelude::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[ruin_runtime::async_main]
|
||||
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";
|
||||
|
||||
App::new()
|
||||
|
||||
@@ -90,21 +90,12 @@ fn TaskBoard() -> impl IntoView {
|
||||
}
|
||||
});
|
||||
|
||||
let all_label = if matches!(filter.get(), TaskFilter::All) {
|
||||
"● All"
|
||||
} else {
|
||||
"○ All"
|
||||
};
|
||||
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 all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All");
|
||||
let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open");
|
||||
let done_label = filter_label(
|
||||
matches!(filter.get(), TaskFilter::CompletedOnly),
|
||||
"Completed",
|
||||
);
|
||||
let task_rows = visible_ids.with(|ids| {
|
||||
ids.iter()
|
||||
.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> {
|
||||
vec![
|
||||
Task {
|
||||
|
||||
@@ -268,6 +268,7 @@ fn RuntimeIoExample(server_addr: SocketAddr) -> impl IntoView {
|
||||
};
|
||||
eprintln!("example05: {message}");
|
||||
let _ = save_status.set(Some(message));
|
||||
flush_reactive_updates();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
16
lib/ruin_app/macos/Info.plist
Normal file
16
lib/ruin_app/macos/Info.plist
Normal 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>
|
||||
@@ -15,8 +15,8 @@ use std::marker::PhantomData;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use ruin_reactivity::effect;
|
||||
use ruin_runtime::queue_future;
|
||||
use ruin_reactivity::{current as current_reactor, effect};
|
||||
use ruin_runtime::{queue_future, spawn_worker};
|
||||
use ruin_ui::{
|
||||
Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree,
|
||||
KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent,
|
||||
@@ -26,7 +26,7 @@ use ruin_ui::{
|
||||
layout_snapshot_with_cache,
|
||||
};
|
||||
#[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")]
|
||||
use ruin_ui_platform_wayland::start_wayland_ui;
|
||||
|
||||
@@ -98,8 +98,7 @@ impl App {
|
||||
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
|
||||
MountedApp {
|
||||
window: self.window,
|
||||
root: Rc::new(root),
|
||||
render_state: Rc::new(RenderState::default()),
|
||||
root,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,19 +149,18 @@ impl Mountable for Element {
|
||||
|
||||
pub struct MountedApp<M: Mountable> {
|
||||
window: Window,
|
||||
root: Rc<M>,
|
||||
render_state: Rc<RenderState>,
|
||||
root: M,
|
||||
}
|
||||
|
||||
impl<M: Mountable> MountedApp<M> {
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let MountedApp {
|
||||
window: app_window,
|
||||
root,
|
||||
render_state,
|
||||
} = self;
|
||||
|
||||
let mut ui = start_platform_ui();
|
||||
async fn run_with_ui_runtime(
|
||||
window: Window,
|
||||
root: M,
|
||||
mut ui: ruin_ui::UiRuntime,
|
||||
) -> Result<()> {
|
||||
let root = Rc::new(root);
|
||||
let render_state = Rc::new(RenderState::default());
|
||||
let app_window = window;
|
||||
let window = ui.create_window(app_window.spec.clone())?;
|
||||
let initial_viewport = app_window
|
||||
.spec
|
||||
@@ -170,6 +168,7 @@ impl<M: Mountable> MountedApp<M> {
|
||||
.unwrap_or(UiSize::new(960.0, 640.0));
|
||||
|
||||
let viewport = ruin_reactivity::cell(initial_viewport);
|
||||
let configure_serial = ruin_reactivity::cell(0_u64);
|
||||
let scene_version = StdCell::new(0_u64);
|
||||
let text_system = Rc::new(RefCell::new(TextSystem::new()));
|
||||
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
|
||||
@@ -183,6 +182,7 @@ impl<M: Mountable> MountedApp<M> {
|
||||
let _scene_effect = effect({
|
||||
let window = window.clone();
|
||||
let viewport = viewport.clone();
|
||||
let configure_serial = configure_serial.clone();
|
||||
let text_system = Rc::clone(&text_system);
|
||||
let layout_cache = Rc::clone(&layout_cache);
|
||||
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);
|
||||
move || {
|
||||
let viewport = viewport.get();
|
||||
let _ = configure_serial.get();
|
||||
let version = scene_version.get().wrapping_add(1);
|
||||
scene_version.set(version);
|
||||
let _ = text_selection.version.get();
|
||||
@@ -266,6 +267,8 @@ impl<M: Mountable> MountedApp<M> {
|
||||
"app received Configured, queuing layout effect"
|
||||
);
|
||||
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() => {
|
||||
Self::handle_pointer_event(
|
||||
@@ -276,6 +279,7 @@ impl<M: Mountable> MountedApp<M> {
|
||||
&mut input_state,
|
||||
event,
|
||||
)?;
|
||||
current_reactor().flush_now();
|
||||
}
|
||||
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
|
||||
Self::handle_keyboard_event(
|
||||
@@ -285,11 +289,22 @@ impl<M: Mountable> MountedApp<M> {
|
||||
&input_state,
|
||||
event,
|
||||
)?;
|
||||
current_reactor().flush_now();
|
||||
}
|
||||
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));
|
||||
}
|
||||
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()?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -301,6 +316,14 @@ impl<M: Mountable> MountedApp<M> {
|
||||
ui.shutdown()?;
|
||||
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(
|
||||
window: &WindowController,
|
||||
@@ -453,13 +476,46 @@ impl<M: Mountable> MountedApp<M> {
|
||||
if selection_changed {
|
||||
text_selection.version.update(|value| *value += 1);
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct View {
|
||||
element: Element,
|
||||
@@ -1307,6 +1363,10 @@ pub fn use_effect(effect: impl Fn() + 'static) {
|
||||
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) {
|
||||
with_render_context_state(|context| {
|
||||
context.side_effects.borrow_mut().window_title = Some(compute());
|
||||
@@ -1520,6 +1580,7 @@ where
|
||||
let result = future.await;
|
||||
if generation.get() == next_generation {
|
||||
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,
|
||||
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
|
||||
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
|
||||
WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
|
||||
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource,
|
||||
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
|
||||
view,
|
||||
WidgetRef, Window, block, button, colors, column, component, context_provider,
|
||||
flush_reactive_updates, provide, row, scroll_box, surfaces, text, use_context, use_effect,
|
||||
use_memo, use_resource, use_shortcut, use_shortcut_with_context, use_signal,
|
||||
use_widget_ref, use_window_title, view,
|
||||
};
|
||||
pub use ruin_ui::{
|
||||
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,
|
||||
|
||||
@@ -77,7 +77,7 @@ pub use platform::current::driver::{
|
||||
pub use platform::current::runtime::{
|
||||
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
|
||||
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.
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
|
||||
@@ -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() {
|
||||
drain_driver_events();
|
||||
drain_remote_tasks();
|
||||
|
||||
@@ -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() {
|
||||
drain_driver_events();
|
||||
drain_remote_tasks();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ libc = "0.2"
|
||||
objc2 = "0.6.4"
|
||||
objc2-core-foundation = "0.3.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
objc2-quartz-core = "0.3.2"
|
||||
raw-window-handle = "0.6"
|
||||
raw-window-metal = "1.1.0"
|
||||
ruin_ui = { path = "../ui" }
|
||||
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
|
||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,7 +91,8 @@ struct PreparedGlyphBitmap {
|
||||
|
||||
struct RasterizedText {
|
||||
origin_offset: Point,
|
||||
size: UiSize,
|
||||
draw_size: UiSize,
|
||||
texture_size: UiSize,
|
||||
pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -170,6 +171,7 @@ struct UploadedAtlasText {
|
||||
struct TextTextureKey {
|
||||
text: String,
|
||||
bounds: Option<(u32, u32)>,
|
||||
clip: Option<(u32, u32, u32, u32)>,
|
||||
font_size_bits: u32,
|
||||
line_height_bits: u32,
|
||||
color: (u8, u8, u8, u8),
|
||||
@@ -330,6 +332,7 @@ pub struct WgpuSceneRenderer {
|
||||
image_cache_order: VecDeque<u64>,
|
||||
#[allow(dead_code)]
|
||||
glyph_atlas: GlyphAtlas,
|
||||
scale_factor: f32,
|
||||
}
|
||||
|
||||
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
||||
@@ -366,8 +369,8 @@ impl WgpuSceneRenderer {
|
||||
format,
|
||||
width: width.max(1),
|
||||
height: height.max(1),
|
||||
present_mode: wgpu::PresentMode::AutoVsync,
|
||||
desired_maximum_frame_latency: 2,
|
||||
present_mode: wgpu::PresentMode::AutoNoVsync,
|
||||
desired_maximum_frame_latency: 1,
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
};
|
||||
@@ -710,6 +713,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_order: VecDeque::new(),
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
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 vertices = build_vertices(scene);
|
||||
let vertices = build_vertices(scene, logical_size);
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(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_texts = Vec::new();
|
||||
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 active_clip = ActiveClip::default();
|
||||
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::Image(image) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -854,21 +885,29 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rasterize_text(&mut self, text: &PreparedText) -> Option<RasterizedText> {
|
||||
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
|
||||
return self.rasterize_prepared_text(text);
|
||||
fn rasterize_text(
|
||||
&mut self,
|
||||
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 = match text.bounds {
|
||||
Some(bounds) => PixelRect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: bounds.width.ceil() as i32,
|
||||
bottom: bounds.height.ceil() as i32,
|
||||
},
|
||||
None => glyph_union(&glyphs)?,
|
||||
};
|
||||
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
|
||||
let glyphs = self.collect_glyph_bitmaps(text, clip);
|
||||
let clip = clip
|
||||
.or_else(|| {
|
||||
text.bounds.map(|bounds| PixelRect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: (bounds.width * self.scale_factor).ceil() as i32,
|
||||
bottom: (bounds.height * self.scale_factor).ceil() as i32,
|
||||
})
|
||||
})
|
||||
.or_else(|| glyph_union(&glyphs))?;
|
||||
if clip.width() == 0 || clip.height() == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -879,23 +918,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
Some(RasterizedText {
|
||||
origin_offset: Point::new(clip.left as f32, clip.top as f32),
|
||||
size: UiSize::new(clip.width() as f32, clip.height() as f32),
|
||||
origin_offset: Point::new(
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 clip = match text.bounds {
|
||||
Some(bounds) => PixelRect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: bounds.width.ceil() as i32,
|
||||
bottom: bounds.height.ceil() as i32,
|
||||
},
|
||||
None => glyph_union_prepared(&glyphs)?,
|
||||
};
|
||||
let clip = raster_clip
|
||||
.map(rect_to_pixel_rect)
|
||||
.or_else(|| {
|
||||
text.bounds.map(|bounds| PixelRect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: bounds.width.ceil() as i32,
|
||||
bottom: bounds.height.ceil() as i32,
|
||||
})
|
||||
})
|
||||
.or_else(|| glyph_union_prepared(&glyphs))?;
|
||||
if clip.width() == 0 || clip.height() == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -914,7 +966,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
|
||||
Some(RasterizedText {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -955,13 +1008,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
glyphs
|
||||
}
|
||||
|
||||
fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec<GlyphBitmap> {
|
||||
let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height));
|
||||
fn collect_glyph_bitmaps(
|
||||
&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);
|
||||
borrowed.set_size(
|
||||
text.bounds.map(|bounds| bounds.width),
|
||||
text.bounds.map(|bounds| bounds.height),
|
||||
text.bounds.map(|bounds| bounds.width * self.scale_factor),
|
||||
text.bounds.map(|bounds| bounds.height * self.scale_factor),
|
||||
);
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
let rect = PixelRect {
|
||||
left: physical.x + image.placement.left,
|
||||
top: physical.y - image.placement.top,
|
||||
right: physical.x + image.placement.left + width,
|
||||
bottom: physical.y - image.placement.top + height,
|
||||
};
|
||||
if raster_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
|
||||
continue;
|
||||
}
|
||||
glyphs.push(GlyphBitmap {
|
||||
rect: PixelRect {
|
||||
left: physical.x + image.placement.left,
|
||||
top: physical.y - image.placement.top,
|
||||
right: physical.x + image.placement.left + width,
|
||||
bottom: physical.y - image.placement.top + height,
|
||||
},
|
||||
rect,
|
||||
content: image.content,
|
||||
color: glyph
|
||||
.color_opt
|
||||
@@ -1003,8 +1067,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
fn prepare_uploaded_atlas_text(
|
||||
&mut self,
|
||||
scene: &SceneSnapshot,
|
||||
logical_size: UiSize,
|
||||
perf: &mut AtlasTextPerfStats,
|
||||
) -> Option<UploadedAtlasText> {
|
||||
if (self.scale_factor - 1.0).abs() > f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let mut vertices = Vec::new();
|
||||
let mut clip_stack = Vec::new();
|
||||
let mut active_clip = ActiveClip::default();
|
||||
@@ -1073,7 +1141,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
glyph_rect,
|
||||
atlas_glyph.atlas_rect,
|
||||
clip_rect,
|
||||
scene.logical_size,
|
||||
logical_size,
|
||||
resolved_color,
|
||||
active_clip,
|
||||
);
|
||||
@@ -1246,9 +1314,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
logical_size: UiSize,
|
||||
clip: ActiveClip,
|
||||
) -> 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) {
|
||||
let rasterized = self.rasterize_text(text)?;
|
||||
let rasterized = self.rasterize_text(text, raster_clip)?;
|
||||
let cached = self.create_cached_text_texture(&rasterized);
|
||||
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 {
|
||||
label: Some("ruin-ui-renderer-wgpu-texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: text.size.width as u32,
|
||||
height: text.size.height as u32,
|
||||
width: text.texture_size.width as u32,
|
||||
height: text.texture_size.height as u32,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
@@ -1337,12 +1406,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
&text.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(text.size.width as u32 * 4),
|
||||
rows_per_image: Some(text.size.height as u32),
|
||||
bytes_per_row: Some(text.texture_size.width as u32 * 4),
|
||||
rows_per_image: Some(text.texture_size.height as u32),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: text.size.width as u32,
|
||||
height: text.size.height as u32,
|
||||
width: text.texture_size.width as u32,
|
||||
height: text.texture_size.height as u32,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
@@ -1365,7 +1434,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
_texture: texture,
|
||||
bind_group,
|
||||
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> {
|
||||
let width = scene.logical_size.width.max(1.0);
|
||||
let height = scene.logical_size.height.max(1.0);
|
||||
fn build_vertices(scene: &SceneSnapshot, logical_size: UiSize) -> Vec<Vertex> {
|
||||
let width = logical_size.width.max(1.0);
|
||||
let height = logical_size.height.max(1.0);
|
||||
let mut vertices = Vec::new();
|
||||
let mut clip_stack = Vec::new();
|
||||
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 {
|
||||
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 {
|
||||
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())]
|
||||
}
|
||||
|
||||
fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
||||
fn text_texture_key(text: &PreparedText, raster_clip: Option<Rect>) -> TextTextureKey {
|
||||
TextTextureKey {
|
||||
text: text.text.clone(),
|
||||
bounds: text
|
||||
.bounds
|
||||
.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(),
|
||||
line_height_bits: text.line_height.to_bits(),
|
||||
color: (
|
||||
@@ -2365,10 +2485,7 @@ mod tests {
|
||||
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
|
||||
push_clip_state, rounded_clip_arrays, text_texture_key,
|
||||
};
|
||||
use ruin_ui::{
|
||||
ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot,
|
||||
TextSelectionStyle, UiSize,
|
||||
};
|
||||
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
|
||||
#[test]
|
||||
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
||||
@@ -2389,7 +2506,7 @@ mod tests {
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
));
|
||||
|
||||
let vertices = build_vertices(&scene);
|
||||
let vertices = build_vertices(&scene, scene.logical_size);
|
||||
assert_eq!(vertices.len(), 12);
|
||||
}
|
||||
|
||||
@@ -2421,7 +2538,10 @@ mod tests {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user