GPUI updates (#51415)

- **Fix race condition in test_collaborating_with_completion**
- **WIP: Integrate scheduler crate into GPUI TestDispatcher**
- **WIP: scheduler integration debugging**
- **Fix formatting**
- **Unify RunnableMeta and add execution tracking to TestScheduler**
- **Remove unused execution tracking from TestScheduler and
TestDispatcher**
- **Add is_ready() to GPUI Task for API parity with scheduler**
- **Eliminate RunnableVariant::Compat - all runnables now have source
location metadata**
- **Update integration plans to reflect completed phases**
- **Simplify RunnableVariant to type alias**
- **Delegate TestDispatcher task queues to TestScheduler (Phase 2b)**
- **Remove waiting_hint/waiting_backtrace and debug logging from
TestDispatcher**
- **Remove wrapper methods from TestDispatcher - access scheduler()
directly**
- **Update integration plan with complete state and instructions for
full scheduler migration**
- **Use scheduler's native timer() and simplify TestDispatcher**
- **Fix rng() usage to lock mutex, update plan with SharedRng wrapper**
- **Add SharedRng wrapper for ergonomic random number generation**
- **Update plan: mark Phase 1 (SharedRng) as complete**
- **Update scheduler integration plan with Phase 2 investigation notes**
- **Phase 3: Delegate simulate_random_delay to
scheduler.yield_random()**
- **Phase 4: Remove TaskLabel**
- **Phase 5 (WIP): Simplify block_internal and remove unparkers**
- **Phase 5 Complete: Scheduler integration finished**
- **Update integration plan with code review findings**
- **Phase 6 & 7: Restore realtime priority support and delete dead
code**
- **Add TestApp and TestAppWindow for cleaner GPUI testing**
- **Fix formatting across the branch**
- **Fix Linux build: add explicit type annotation and rename
probability() to weight()**
- **Add TestApp and TestAppWindow for cleaner GPUI testing**
- **Rename TestAppWindow to TestWindow, internal TestWindow to
TestPlatformWindow**
- **Remove unused RunnableVariant imports on Linux**
- **Add STATUS.md for next agent**
- **Run cargo fmt**
- **Use per-app element arena only and scope test draws**
- **Fix collab tests for scheduler timing and ordering**
- **Store element arena on App and route element allocations through
draw scope**
- **Fix TestScheduler lock ordering between rng and state**
- **Fix inlay hints test by explicitly triggering refresh after viewport
setup**
- **Add scheduler integration regression risk analysis doc**
- **Fix tests: avoid caching Entity in global OnceLock for Codestral API
key**
- **Document learned weak point: global cached Entity handles break
across App contexts**
- **Add scheduler regression test for block_with_timeout continuation
and explicit time advancement**
- **Document TestScheduler timeout tick budget behavior and explicit
time advancement guidance**
- **Add test asserting realtime priority spawns panic under
TestDispatcher**
- **Document realtime priority determinism contract in tests**
- **Remove realtime priority until we have a concrete use case (cc
@localcc)**
- **Update STATUS for scheduler integration decisions and realtime
priority removal**
- **Fix prettier docs and clippy in scheduler tests**
- **Remove unused imports from Windows dispatcher**
- **WIP: scheduler integration debugging + agent terminal diagnostics**
- **Update scheduler integration status**
- **Remove temporary planning docs, consolidate into scheduler
integration doc**
- **Remove unrelated changes from scheduler integration**
- **Fix clippy errors**
- **Add STATUS.md with debugging instructions for Linux/Windows hang**
- **WIP: local changes needed by ex**
- **Add pointer capture API for stable drag handling**
- **Add pointer capture API for stable drag handling**
- **chore: update generated cargo manifests**
- **gpui: Expose ShapedLine::width() for pen advancement**
- **Remove git2 usage from util test.rs**
- **Store DiagnosticQuad bounds in logical Pixels**
- **WIP: executor and test_app changes for scheduler integration**
- **Expose font APIs publicly**
- **gpui: add typed diagnostics and record_diagnostic API**
- **WIP: gpui test window diagnostics changes**
- **Add LineCacheKey trait and shape_line_cached API for
content-addressable shaping**
- **Fix RenderGlyphParams field additions for Ex compatibility**
- **Add doc comment for recommended_rendering_mode, fix formatting**
- **Add scheduler_executor() method for Ex compatibility**
- **Fix TestWindow -> TestPlatformWindow in test_context.rs**
- **Add headless metal renderer and window focus improvements**
- **Fix double borrow in TestWindow::simulate_resize**
- **Fix cbindgen panic: remove default type parameter from
Diagnostic<T>**
- **Implement AppContext for HeadlessMetalAppContext**
- **Missing trait impls**
- **Add ShapedLine::split_at and eliminate re-shaping in soft wraps**
- **Add handoff doc for platform-neutral-tests merge**
- **Remove ex-only test infrastructure before merging main**
- **Add cross-platform HeadlessAppContext with pluggable text system**
- **Export platform_text_system() from gpui_windows for cross-platform
tests**
- **Restore TestApp/TestAppWindow with pluggable text system support**
- **Add TestApp::open_window_sized for tests that need specific window
dimensions**
- **Fix some warnings**
- **Fixes**
- **Add a platform-neutral headless renderer interface**
- **Synchronize Managed texture before CPU readback on discrete GPUs**
- **Allow creating TestDispatcher with custom scheduler**

Release Notes:

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: John Tur <john-tur@outlook.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
This commit is contained in:
Conrad Irwin 2026-03-12 16:15:12 -06:00 committed by GitHub
parent ad1e82e9e2
commit b32067d248
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2314 additions and 79 deletions

View file

@ -27,9 +27,13 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque};
pub use context::*;
pub use entity_map::*;
use gpui_util::{ResultExt, debug_panic};
#[cfg(any(test, feature = "test-support"))]
pub use headless_app_context::*;
use http_client::{HttpClient, Url};
use smallvec::SmallVec;
#[cfg(any(test, feature = "test-support"))]
pub use test_app::*;
#[cfg(any(test, feature = "test-support"))]
pub use test_context::*;
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
pub use visual_test_context::*;
@ -54,6 +58,10 @@ mod async_context;
mod context;
mod entity_map;
#[cfg(any(test, feature = "test-support"))]
mod headless_app_context;
#[cfg(any(test, feature = "test-support"))]
mod test_app;
#[cfg(any(test, feature = "test-support"))]
mod test_context;
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
mod visual_test_context;

View file

@ -0,0 +1,267 @@
//! Cross-platform headless app context for tests that need real text shaping.
//!
//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral
//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem`
//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get
//! accurate glyph measurements while keeping everything else deterministic.
//!
//! Optionally, a renderer factory can be provided to enable real GPU rendering
//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`].
use crate::{
AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds,
Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer,
PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem,
Window, WindowBounds, WindowHandle, WindowOptions,
app::{GpuiBorrow, GpuiMode},
};
use anyhow::Result;
use image::RgbaImage;
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
/// A cross-platform headless app context for tests that need real text shaping.
///
/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses
/// `TestPlatform` for deterministic scheduling and accepts a pluggable
/// `PlatformTextSystem` so tests get real glyph measurements.
///
/// # Usage
///
/// ```ignore
/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback"));
/// let mut cx = HeadlessAppContext::with_platform(
/// text_system,
/// Arc::new(Assets),
/// || gpui_platform::current_headless_renderer(),
/// );
/// ```
pub struct HeadlessAppContext {
/// The underlying app cell.
pub app: Rc<AppCell>,
/// The background executor for running async tasks.
pub background_executor: BackgroundExecutor,
/// The foreground executor for running tasks on the main thread.
pub foreground_executor: ForegroundExecutor,
dispatcher: TestDispatcher,
text_system: Arc<TextSystem>,
}
impl HeadlessAppContext {
/// Creates a new headless app context with the given text system.
pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
Self::with_platform(platform_text_system, Arc::new(()), || None)
}
/// Creates a new headless app context with a custom text system and asset source.
pub fn with_asset_source(
platform_text_system: Arc<dyn PlatformTextSystem>,
asset_source: Arc<dyn AssetSource>,
) -> Self {
Self::with_platform(platform_text_system, asset_source, || None)
}
/// Creates a new headless app context with the given text system, asset source,
/// and an optional renderer factory for screenshot support.
pub fn with_platform(
platform_text_system: Arc<dyn PlatformTextSystem>,
asset_source: Arc<dyn AssetSource>,
renderer_factory: impl Fn() -> Option<Box<dyn PlatformHeadlessRenderer>> + 'static,
) -> Self {
let seed = std::env::var("SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let dispatcher = TestDispatcher::new(seed);
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let renderer_factory: Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>> =
Box::new(renderer_factory);
let platform = TestPlatform::with_platform(
background_executor.clone(),
foreground_executor.clone(),
platform_text_system.clone(),
Some(renderer_factory),
);
let text_system = Arc::new(TextSystem::new(platform_text_system));
let http_client = http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
app.borrow_mut().mode = GpuiMode::test();
Self {
app,
background_executor,
foreground_executor,
dispatcher,
text_system,
}
}
/// Opens a window for headless rendering.
pub fn open_window<V: Render + 'static>(
&mut self,
size: Size<Pixels>,
build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
) -> Result<WindowHandle<V>> {
use crate::{point, px};
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size,
};
let mut cx = self.app.borrow_mut();
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
focus: false,
show: false,
..Default::default()
},
build_root,
)
}
/// Runs all pending tasks until parked.
pub fn run_until_parked(&self) {
self.dispatcher.run_until_parked();
}
/// Advances the simulated clock.
pub fn advance_clock(&self, duration: Duration) {
self.dispatcher.advance_clock(duration);
}
/// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading).
pub fn allow_parking(&self) {
self.dispatcher.allow_parking();
}
/// Disables parking mode, returning to deterministic test execution.
pub fn forbid_parking(&self) {
self.dispatcher.forbid_parking();
}
/// Updates app state.
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
let mut app = self.app.borrow_mut();
f(&mut app)
}
/// Updates a window and calls draw to render.
pub fn update_window<R>(
&mut self,
window: AnyWindowHandle,
f: impl FnOnce(AnyView, &mut Window, &mut App) -> R,
) -> Result<R> {
let mut app = self.app.borrow_mut();
app.update_window(window, f)
}
/// Captures a screenshot from a window.
///
/// Requires that the context was created with a renderer factory that
/// returns `Some` via [`HeadlessAppContext::with_platform`].
pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
let mut app = self.app.borrow_mut();
app.update_window(window, |_, window, _| window.render_to_image())?
}
/// Returns the text system.
pub fn text_system(&self) -> &Arc<TextSystem> {
&self.text_system
}
/// Returns the background executor.
pub fn background_executor(&self) -> &BackgroundExecutor {
&self.background_executor
}
/// Returns the foreground executor.
pub fn foreground_executor(&self) -> &ForegroundExecutor {
&self.foreground_executor
}
}
impl AppContext for HeadlessAppContext {
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
let mut app = self.app.borrow_mut();
app.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
let mut app = self.app.borrow_mut();
app.reserve_entity()
}
fn insert_entity<T: 'static>(
&mut self,
reservation: Reservation<T>,
build_entity: impl FnOnce(&mut Context<T>) -> T,
) -> Entity<T> {
let mut app = self.app.borrow_mut();
app.insert_entity(reservation, build_entity)
}
fn update_entity<T: 'static, R>(
&mut self,
handle: &Entity<T>,
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> R {
let mut app = self.app.borrow_mut();
app.update_entity(handle, update)
}
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> GpuiBorrow<'a, T>
where
T: 'static,
{
panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.")
}
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
where
T: 'static,
{
let app = self.app.borrow();
app.read_entity(handle, read)
}
fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
where
F: FnOnce(AnyView, &mut Window, &mut App) -> T,
{
let mut lock = self.app.borrow_mut();
lock.update_window(window, f)
}
fn read_window<T, R>(
&self,
window: &WindowHandle<T>,
read: impl FnOnce(Entity<T>, &App) -> R,
) -> Result<R>
where
T: 'static,
{
let app = self.app.borrow();
app.read_window(window, read)
}
fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
where
R: Send + 'static,
{
self.background_executor.spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
where
G: Global,
{
let app = self.app.borrow();
app.read_global(callback)
}
}

View file

@ -0,0 +1,607 @@
//! A clean testing API for GPUI applications.
//!
//! `TestApp` provides a simpler alternative to `TestAppContext` with:
//! - Automatic effect flushing after updates
//! - Clean window creation and inspection
//! - Input simulation helpers
//!
//! # Example
//! ```ignore
//! #[test]
//! fn test_my_view() {
//! let mut app = TestApp::new();
//!
//! let mut window = app.open_window(|window, cx| {
//! MyView::new(window, cx)
//! });
//!
//! window.update(|view, window, cx| {
//! view.do_something(cx);
//! });
//!
//! // Check rendered state
//! assert_eq!(window.title(), Some("Expected Title"));
//! }
//! ```
use crate::{
AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext,
Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform,
PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem,
Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode,
};
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
/// A test application context with a clean API.
///
/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after
/// each update and provides simpler window management.
pub struct TestApp {
app: Rc<AppCell>,
platform: Rc<TestPlatform>,
background_executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
#[allow(dead_code)]
dispatcher: TestDispatcher,
text_system: Arc<TextSystem>,
}
impl TestApp {
/// Create a new test application.
pub fn new() -> Self {
Self::with_seed(0)
}
/// Create a new test application with a specific random seed.
pub fn with_seed(seed: u64) -> Self {
Self::build(seed, None, Arc::new(()))
}
/// Create a new test application with a custom text system for real font shaping.
pub fn with_text_system(text_system: Arc<dyn PlatformTextSystem>) -> Self {
Self::build(0, Some(text_system), Arc::new(()))
}
/// Create a new test application with a custom text system and asset source.
pub fn with_text_system_and_assets(
text_system: Arc<dyn PlatformTextSystem>,
asset_source: Arc<dyn crate::AssetSource>,
) -> Self {
Self::build(0, Some(text_system), asset_source)
}
fn build(
seed: u64,
platform_text_system: Option<Arc<dyn PlatformTextSystem>>,
asset_source: Arc<dyn crate::AssetSource>,
) -> Self {
let dispatcher = TestDispatcher::new(seed);
let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = match platform_text_system.clone() {
Some(ts) => TestPlatform::with_text_system(
background_executor.clone(),
foreground_executor.clone(),
ts,
),
None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()),
};
let http_client = http_client::FakeHttpClient::with_404_response();
let text_system = Arc::new(TextSystem::new(
platform_text_system.unwrap_or_else(|| platform.text_system.clone()),
));
let app = App::new_app(platform.clone(), asset_source, http_client);
app.borrow_mut().mode = GpuiMode::test();
Self {
app,
platform,
background_executor,
foreground_executor,
dispatcher,
text_system,
}
}
/// Run a closure with mutable access to the App context.
/// Automatically runs until parked after the closure completes.
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
let result = {
let mut app = self.app.borrow_mut();
app.update(f)
};
self.run_until_parked();
result
}
/// Run a closure with read-only access to the App context.
pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
let app = self.app.borrow();
f(&app)
}
/// Create a new entity in the app.
pub fn new_entity<T: 'static>(
&mut self,
build: impl FnOnce(&mut Context<T>) -> T,
) -> Entity<T> {
self.update(|cx| cx.new(build))
}
/// Update an entity.
pub fn update_entity<T: 'static, R>(
&mut self,
entity: &Entity<T>,
f: impl FnOnce(&mut T, &mut Context<T>) -> R,
) -> R {
self.update(|cx| entity.update(cx, f))
}
/// Read an entity.
pub fn read_entity<T: 'static, R>(
&self,
entity: &Entity<T>,
f: impl FnOnce(&T, &App) -> R,
) -> R {
self.read(|cx| f(entity.read(cx), cx))
}
/// Open a test window with the given root view, using maximized bounds.
pub fn open_window<V: Render + 'static>(
&mut self,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> TestAppWindow<V> {
let bounds = self.read(|cx| Bounds::maximized(None, cx));
let handle = self.update(|cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| build_view(window, cx)),
)
.unwrap()
});
TestAppWindow {
handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
/// Open a test window with specific options.
pub fn open_window_with_options<V: Render + 'static>(
&mut self,
options: WindowOptions,
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
) -> TestAppWindow<V> {
let handle = self.update(|cx| {
cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx)))
.unwrap()
});
TestAppWindow {
handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
/// Run pending tasks until there's nothing left to do.
pub fn run_until_parked(&self) {
self.background_executor.run_until_parked();
}
/// Advance the simulated clock by the given duration.
pub fn advance_clock(&self, duration: Duration) {
self.background_executor.advance_clock(duration);
}
/// Spawn a future on the foreground executor.
pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task<R>
where
Fut: Future<Output = R> + 'static,
R: 'static,
{
self.foreground_executor.spawn(f(self.to_async()))
}
/// Spawn a future on the background executor.
pub fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
where
R: Send + 'static,
{
self.background_executor.spawn(future)
}
/// Get an async handle to the app.
pub fn to_async(&self) -> AsyncApp {
AsyncApp {
app: Rc::downgrade(&self.app),
background_executor: self.background_executor.clone(),
foreground_executor: self.foreground_executor.clone(),
}
}
/// Get the background executor.
pub fn background_executor(&self) -> &BackgroundExecutor {
&self.background_executor
}
/// Get the foreground executor.
pub fn foreground_executor(&self) -> &ForegroundExecutor {
&self.foreground_executor
}
/// Get the text system.
pub fn text_system(&self) -> &Arc<TextSystem> {
&self.text_system
}
/// Check if a global of the given type exists.
pub fn has_global<G: Global>(&self) -> bool {
self.read(|cx| cx.has_global::<G>())
}
/// Set a global value.
pub fn set_global<G: Global>(&mut self, global: G) {
self.update(|cx| cx.set_global(global));
}
/// Read a global value.
pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
self.read(|cx| f(cx.global(), cx))
}
/// Update a global value.
pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
self.update(|cx| cx.update_global(f))
}
// Platform simulation methods
/// Write text to the simulated clipboard.
pub fn write_to_clipboard(&self, item: ClipboardItem) {
self.platform.write_to_clipboard(item);
}
/// Read from the simulated clipboard.
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.platform.read_from_clipboard()
}
/// Get URLs that have been opened via `cx.open_url()`.
pub fn opened_url(&self) -> Option<String> {
self.platform.opened_url.borrow().clone()
}
/// Check if a file path prompt is pending.
pub fn did_prompt_for_new_path(&self) -> bool {
self.platform.did_prompt_for_new_path()
}
/// Simulate answering a path selection dialog.
pub fn simulate_new_path_selection(
&self,
select: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>,
) {
self.platform.simulate_new_path_selection(select);
}
/// Check if a prompt dialog is pending.
pub fn has_pending_prompt(&self) -> bool {
self.platform.has_pending_prompt()
}
/// Simulate answering a prompt dialog.
pub fn simulate_prompt_answer(&self, button: &str) {
self.platform.simulate_prompt_answer(button);
}
/// Get all open windows.
pub fn windows(&self) -> Vec<AnyWindowHandle> {
self.read(|cx| cx.windows())
}
}
impl Default for TestApp {
fn default() -> Self {
Self::new()
}
}
/// A test window with inspection and simulation capabilities.
pub struct TestAppWindow<V> {
handle: WindowHandle<V>,
app: Rc<AppCell>,
platform: Rc<TestPlatform>,
background_executor: BackgroundExecutor,
}
impl<V: 'static + Render> TestAppWindow<V> {
/// Get the window handle.
pub fn handle(&self) -> WindowHandle<V> {
self.handle
}
/// Get the root view entity.
pub fn root(&self) -> Entity<V> {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |root_view, _, _| {
root_view.downcast::<V>().expect("root view type mismatch")
})
.expect("window not found")
}
/// Update the root view.
pub fn update<R>(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context<V>) -> R) -> R {
let result = {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |root_view, window, cx| {
let view = root_view.downcast::<V>().expect("root view type mismatch");
view.update(cx, |view, cx| f(view, window, cx))
})
.expect("window not found")
};
self.background_executor.run_until_parked();
result
}
/// Read the root view.
pub fn read<R>(&self, f: impl FnOnce(&V, &App) -> R) -> R {
let app = self.app.borrow();
let view = self
.app
.borrow()
.windows
.get(self.handle.window_id())
.and_then(|w| w.as_ref())
.and_then(|w| w.root.clone())
.and_then(|r| r.downcast::<V>().ok())
.expect("window or root view not found");
f(view.read(&app), &app)
}
/// Get the window title.
pub fn title(&self) -> Option<String> {
let app = self.app.borrow();
app.read_window(&self.handle, |_, _cx| {
// TODO: expose title through Window API
None
})
.unwrap()
}
/// Simulate a keystroke.
pub fn simulate_keystroke(&mut self, keystroke: &str) {
let keystroke = Keystroke::parse(keystroke).unwrap();
{
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.dispatch_keystroke(keystroke, cx);
})
.unwrap();
}
self.background_executor.run_until_parked();
}
/// Simulate multiple keystrokes (space-separated).
pub fn simulate_keystrokes(&mut self, keystrokes: &str) {
for keystroke in keystrokes.split(' ') {
self.simulate_keystroke(keystroke);
}
}
/// Simulate typing text.
pub fn simulate_input(&mut self, input: &str) {
for char in input.chars() {
self.simulate_keystroke(&char.to_string());
}
}
/// Simulate a mouse move.
pub fn simulate_mouse_move(&mut self, position: Point<Pixels>) {
self.simulate_event(MouseMoveEvent {
position,
modifiers: Default::default(),
pressed_button: None,
});
}
/// Simulate a mouse down event.
pub fn simulate_mouse_down(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_event(MouseDownEvent {
position,
button,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
});
}
/// Simulate a mouse up event.
pub fn simulate_mouse_up(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_event(MouseUpEvent {
position,
button,
modifiers: Default::default(),
click_count: 1,
});
}
/// Simulate a click at the given position.
pub fn simulate_click(&mut self, position: Point<Pixels>, button: MouseButton) {
self.simulate_mouse_down(position, button);
self.simulate_mouse_up(position, button);
}
/// Simulate a scroll event.
pub fn simulate_scroll(&mut self, position: Point<Pixels>, delta: Point<Pixels>) {
self.simulate_event(crate::ScrollWheelEvent {
position,
delta: crate::ScrollDelta::Pixels(delta),
modifiers: Default::default(),
touch_phase: crate::TouchPhase::Moved,
});
}
/// Simulate an input event.
pub fn simulate_event<E: InputEvent>(&mut self, event: E) {
let platform_input = event.to_platform_input();
{
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.dispatch_event(platform_input, cx);
})
.unwrap();
}
self.background_executor.run_until_parked();
}
/// Simulate resizing the window.
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
let window_id = self.handle.window_id();
let mut app = self.app.borrow_mut();
if let Some(Some(window)) = app.windows.get_mut(window_id) {
if let Some(test_window) = window.platform_window.as_test() {
test_window.simulate_resize(size);
}
}
drop(app);
self.background_executor.run_until_parked();
}
/// Force a redraw of the window.
pub fn draw(&mut self) {
let mut app = self.app.borrow_mut();
let any_handle: AnyWindowHandle = self.handle.into();
app.update_window(any_handle, |_, window, cx| {
window.draw(cx).clear();
})
.unwrap();
}
}
impl<V> Clone for TestAppWindow<V> {
fn clone(&self) -> Self {
Self {
handle: self.handle,
app: self.app.clone(),
platform: self.platform.clone(),
background_executor: self.background_executor.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FocusHandle, Focusable, div, prelude::*};
struct Counter {
count: usize,
focus_handle: FocusHandle,
}
impl Counter {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
Self {
count: 0,
focus_handle,
}
}
fn increment(&mut self, _cx: &mut Context<Self>) {
self.count += 1;
}
}
impl Focusable for Counter {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("Count: {}", self.count))
}
}
#[test]
fn test_basic_usage() {
let mut app = TestApp::new();
let mut window = app.open_window(Counter::new);
window.update(|counter, _window, cx| {
counter.increment(cx);
});
window.read(|counter, _| {
assert_eq!(counter.count, 1);
});
drop(window);
app.update(|cx| cx.shutdown());
}
#[test]
fn test_entity_creation() {
let mut app = TestApp::new();
let entity = app.new_entity(|cx| Counter {
count: 42,
focus_handle: cx.focus_handle(),
});
app.read_entity(&entity, |counter, _| {
assert_eq!(counter.count, 42);
});
app.update_entity(&entity, |counter, _cx| {
counter.count += 1;
});
app.read_entity(&entity, |counter, _| {
assert_eq!(counter.count, 43);
});
}
#[test]
fn test_globals() {
let mut app = TestApp::new();
struct MyGlobal(String);
impl Global for MyGlobal {}
assert!(!app.has_global::<MyGlobal>());
app.set_global(MyGlobal("hello".into()));
assert!(app.has_global::<MyGlobal>());
app.read_global::<MyGlobal, _>(|global, _| {
assert_eq!(global.0, "hello");
});
app.update_global::<MyGlobal, _>(|global, _| {
global.0 = "world".into();
});
app.read_global::<MyGlobal, _>(|global, _| {
assert_eq!(global.0, "world");
});
}
}

View file

@ -231,6 +231,33 @@ impl TestAppContext {
.unwrap()
}
/// Opens a new window with a specific size.
///
/// Unlike `add_window` which uses maximized bounds, this allows controlling
/// the window dimensions, which is important for layout-sensitive tests.
pub fn open_window<F, V>(
&mut self,
window_size: Size<Pixels>,
build_window: F,
) -> WindowHandle<V>
where
F: FnOnce(&mut Window, &mut Context<V>) -> V,
V: 'static + Render,
{
let mut cx = self.app.borrow_mut();
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds {
origin: Point::default(),
size: window_size,
})),
..Default::default()
},
|window, cx| cx.new(|cx| build_window(window, cx)),
)
.unwrap()
}
/// Adds a new window with no content.
pub fn add_empty_window(&mut self) -> &mut VisualTestContext {
let mut cx = self.app.borrow_mut();

View file

@ -820,6 +820,15 @@ impl LinearColorStop {
}
impl Background {
/// Returns the solid color if this is a solid background, None otherwise.
pub fn as_solid(&self) -> Option<Hsla> {
if self.tag == BackgroundTag::Solid {
Some(self.solid)
} else {
None
}
}
/// Use specified color space for color interpolation.
///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method>

View file

@ -129,6 +129,13 @@ impl BackgroundExecutor {
}
}
/// Returns the underlying scheduler::BackgroundExecutor.
///
/// This is used by Ex to pass the executor to thread/worktree code.
pub fn scheduler_executor(&self) -> scheduler::BackgroundExecutor {
self.inner.clone()
}
/// Enqueues the given future to be run to completion on a background thread.
#[track_caller]
pub fn spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>

View file

@ -555,6 +555,20 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
}
}
/// A renderer for headless windows that can produce real rendered output.
#[cfg(any(test, feature = "test-support"))]
pub trait PlatformHeadlessRenderer {
/// Render a scene and return the result as an RGBA image.
fn render_scene_to_image(
&mut self,
scene: &Scene,
size: Size<DevicePixels>,
) -> Result<RgbaImage>;
/// Returns the sprite atlas used by this renderer.
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
}
/// Type alias for runnables with metadata.
/// Previously an enum with a single variant, now simplified to a direct type alias.
#[doc(hidden)]
@ -573,6 +587,7 @@ pub trait PlatformDispatcher: Send + Sync {
fn dispatch(&self, runnable: RunnableVariant, priority: Priority);
fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority);
fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant);
fn spawn_realtime(&self, f: Box<dyn FnOnce() + Send>);
fn now(&self) -> Instant {
@ -592,19 +607,29 @@ pub trait PlatformDispatcher: Send + Sync {
#[expect(missing_docs)]
pub trait PlatformTextSystem: Send + Sync {
fn add_fonts(&self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()>;
/// Get all available font names.
fn all_font_names(&self) -> Vec<String>;
/// Get the font ID for a font descriptor.
fn font_id(&self, descriptor: &Font) -> Result<FontId>;
/// Get metrics for a font.
fn font_metrics(&self, font_id: FontId) -> FontMetrics;
/// Get typographic bounds for a glyph.
fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Bounds<f32>>;
/// Get the advance width for a glyph.
fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Size<f32>>;
/// Get the glyph ID for a character.
fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId>;
/// Get raster bounds for a glyph.
fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>>;
/// Rasterize a glyph.
fn rasterize_glyph(
&self,
params: &RenderGlyphParams,
raster_bounds: Bounds<DevicePixels>,
) -> Result<(Size<DevicePixels>, Vec<u8>)>;
/// Layout a line of text with the given font runs.
fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout;
/// Returns the recommended text rendering mode for the given font and size.
fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels)
-> TextRenderingMode;
}

View file

@ -30,11 +30,12 @@ impl TestDispatcher {
.map_or(false, |var| var == "1" || var == "true"),
timeout_ticks: 0..=1000,
}));
Self::from_scheduler(scheduler)
}
let session_id = scheduler.allocate_session_id();
pub fn from_scheduler(scheduler: Arc<TestScheduler>) -> Self {
TestDispatcher {
session_id,
session_id: scheduler.allocate_session_id(),
scheduler,
num_cpus_override: Arc::new(AtomicUsize::new(0)),
}
@ -76,6 +77,14 @@ impl TestDispatcher {
while self.tick(false) {}
}
pub fn allow_parking(&self) {
self.scheduler.allow_parking();
}
pub fn forbid_parking(&self) {
self.scheduler.forbid_parking();
}
/// Override the value returned by `BackgroundExecutor::num_cpus()` in tests.
/// A value of 0 means no override (the default of 4 is used).
pub fn set_num_cpus(&self, count: usize) {

View file

@ -1,9 +1,9 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton,
ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task,
TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
use collections::VecDeque;
@ -34,6 +34,7 @@ pub(crate) struct TestPlatform {
pub opened_url: RefCell<Option<String>>,
pub text_system: Arc<dyn PlatformTextSystem>,
pub expect_restart: RefCell<Option<oneshot::Sender<Option<PathBuf>>>>,
headless_renderer_factory: Option<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
weak: Weak<Self>,
}
@ -88,8 +89,30 @@ pub(crate) struct TestPrompts {
impl TestPlatform {
pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc<Self> {
let text_system = Arc::new(NoopTextSystem);
Self::with_platform(
executor,
foreground_executor,
Arc::new(NoopTextSystem),
None,
)
}
pub fn with_text_system(
executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
text_system: Arc<dyn PlatformTextSystem>,
) -> Rc<Self> {
Self::with_platform(executor, foreground_executor, text_system, None)
}
pub fn with_platform(
executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
text_system: Arc<dyn PlatformTextSystem>,
headless_renderer_factory: Option<
Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>,
>,
) -> Rc<Self> {
Rc::new_cyclic(|weak| TestPlatform {
background_executor: executor,
foreground_executor,
@ -107,6 +130,7 @@ impl TestPlatform {
weak: weak.clone(),
opened_url: Default::default(),
text_system,
headless_renderer_factory,
})
}
@ -299,11 +323,13 @@ impl Platform for TestPlatform {
handle: AnyWindowHandle,
params: WindowParams,
) -> anyhow::Result<Box<dyn crate::PlatformWindow>> {
let renderer = self.headless_renderer_factory.as_ref().and_then(|f| f());
let window = TestWindow::new(
handle,
params,
self.weak.clone(),
self.active_display.clone(),
renderer,
);
Ok(Box::new(window))
}

View file

@ -1,10 +1,12 @@
use crate::{
AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs,
Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance,
AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels,
DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay,
PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance,
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams,
};
use collections::HashMap;
use image::RgbaImage;
use parking_lot::Mutex;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::{
@ -21,6 +23,7 @@ pub(crate) struct TestWindowState {
platform: Weak<TestPlatform>,
// TODO: Replace with `Rc`
sprite_atlas: Arc<dyn PlatformAtlas>,
renderer: Option<Box<dyn PlatformHeadlessRenderer>>,
pub(crate) should_close_handler: Option<Box<dyn FnMut() -> bool>>,
hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
input_callback: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,
@ -57,13 +60,19 @@ impl TestWindow {
params: WindowParams,
platform: Weak<TestPlatform>,
display: Rc<dyn PlatformDisplay>,
renderer: Option<Box<dyn PlatformHeadlessRenderer>>,
) -> Self {
let sprite_atlas: Arc<dyn PlatformAtlas> = match &renderer {
Some(r) => r.sprite_atlas(),
None => Arc::new(TestAtlas::new()),
};
Self(Rc::new(Mutex::new(TestWindowState {
bounds: params.bounds,
display,
platform,
handle,
sprite_atlas: Arc::new(TestAtlas::new()),
sprite_atlas,
renderer,
title: Default::default(),
edited: false,
should_close_handler: None,
@ -81,10 +90,11 @@ impl TestWindow {
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
let scale_factor = self.scale_factor();
let mut lock = self.0.lock();
// Always update bounds, even if no callback is registered
lock.bounds.size = size;
let Some(mut callback) = lock.resize_callback.take() else {
return;
};
lock.bounds.size = size;
drop(lock);
callback(size, scale_factor);
self.0.lock().resize_callback = Some(callback);
@ -275,12 +285,25 @@ impl PlatformWindow for TestWindow {
fn on_appearance_changed(&self, _callback: Box<dyn FnMut()>) {}
fn draw(&self, _scene: &crate::Scene) {}
fn draw(&self, _scene: &Scene) {}
fn sprite_atlas(&self) -> sync::Arc<dyn crate::PlatformAtlas> {
self.0.lock().sprite_atlas.clone()
}
#[cfg(any(test, feature = "test-support"))]
fn render_to_image(&self, scene: &Scene) -> anyhow::Result<RgbaImage> {
let mut state = self.0.lock();
let size = state.bounds.size;
if let Some(renderer) = &mut state.renderer {
let scale_factor = 2.0;
let device_size: Size<DevicePixels> = size.to_device_pixels(scale_factor);
renderer.render_scene_to_image(scene, device_size)
} else {
anyhow::bail!("render_to_image not available: no HeadlessRenderer configured")
}
}
fn as_test(&mut self) -> Option<&mut TestWindow> {
Some(self)
}

View file

@ -657,7 +657,7 @@ impl Default for TransformationMatrix {
#[expect(missing_docs)]
pub struct MonochromeSprite {
pub order: DrawOrder,
pub pad: u32, // align to 8 bytes
pub pad: u32,
pub bounds: Bounds<ScaledPixels>,
pub content_mask: ContentMask<ScaledPixels>,
pub color: Hsla,
@ -695,7 +695,7 @@ impl From<SubpixelSprite> for Primitive {
#[expect(missing_docs)]
pub struct PolychromeSprite {
pub order: DrawOrder,
pub pad: u32, // align to 8 bytes
pub pad: u32,
pub grayscale: bool,
pub opacity: f32,
pub bounds: Bounds<ScaledPixels>,

View file

@ -63,7 +63,8 @@ pub struct TextSystem {
}
impl TextSystem {
pub(crate) fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
/// Create a new TextSystem with the given platform text system.
pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
TextSystem {
platform_text_system,
font_metrics: RwLock::default(),
@ -372,7 +373,8 @@ pub struct WindowTextSystem {
}
impl WindowTextSystem {
pub(crate) fn new(text_system: Arc<TextSystem>) -> Self {
/// Create a new WindowTextSystem with the given TextSystem.
pub fn new(text_system: Arc<TextSystem>) -> Self {
Self {
line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()),
text_system,
@ -438,6 +440,74 @@ impl WindowTextSystem {
}
}
/// Shape the given line using a caller-provided content hash as the cache key.
///
/// This enables cache hits without materializing a contiguous `SharedString` for the text.
/// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
///
/// Contract (caller enforced):
/// - Same `text_hash` implies identical text content (collision risk accepted by caller).
/// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
///
/// Like [`Self::shape_line`], this must be used only for single-line text (no `\n`).
pub fn shape_line_by_hash(
&self,
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &[TextRun],
force_width: Option<Pixels>,
materialize_text: impl FnOnce() -> SharedString,
) -> ShapedLine {
let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new();
for run in runs {
if let Some(last_run) = decoration_runs.last_mut()
&& last_run.color == run.color
&& last_run.underline == run.underline
&& last_run.strikethrough == run.strikethrough
&& last_run.background_color == run.background_color
{
last_run.len += run.len as u32;
continue;
}
decoration_runs.push(DecorationRun {
len: run.len as u32,
color: run.color,
background_color: run.background_color,
underline: run.underline,
strikethrough: run.strikethrough,
});
}
let mut used_force_width = force_width;
let layout = self.layout_line_by_hash(
text_hash,
text_len,
font_size,
runs,
used_force_width,
|| {
let text = materialize_text();
debug_assert!(
text.find('\n').is_none(),
"text argument should not contain newlines"
);
text
},
);
// We only materialize actual text on cache miss; on hit we avoid allocations.
// Since `ShapedLine` carries a `SharedString`, use an empty placeholder for hits.
// NOTE: Callers must not rely on `ShapedLine.text` for content when using this API.
let text: SharedString = SharedString::new_static("");
ShapedLine {
layout,
text,
decoration_runs,
}
}
/// Shape a multi line string of text, at the given font_size, for painting to the screen.
/// Subsets of the text can be styled independently with the `runs` parameter.
/// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width.
@ -627,6 +697,130 @@ impl WindowTextSystem {
layout
}
/// Probe the line layout cache using a caller-provided content hash, without allocating.
///
/// Returns `Some(layout)` if the layout is already cached in either the current frame
/// or the previous frame. Returns `None` if it is not cached.
///
/// Contract (caller enforced):
/// - Same `text_hash` implies identical text content (collision risk accepted by caller).
/// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
pub fn try_layout_line_by_hash(
&self,
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &[TextRun],
force_width: Option<Pixels>,
) -> Option<Arc<LineLayout>> {
let mut last_run = None::<&TextRun>;
let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
font_runs.clear();
for run in runs.iter() {
let decoration_changed = if let Some(last_run) = last_run
&& last_run.color == run.color
&& last_run.underline == run.underline
&& last_run.strikethrough == run.strikethrough
// we do not consider differing background color relevant, as it does not affect glyphs
// && last_run.background_color == run.background_color
{
false
} else {
last_run = Some(run);
true
};
let font_id = self.resolve_font(&run.font);
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& !decoration_changed
{
font_run.len += run.len;
} else {
font_runs.push(FontRun {
len: run.len,
font_id,
});
}
}
let layout = self.line_layout_cache.try_layout_line_by_hash(
text_hash,
text_len,
font_size,
&font_runs,
force_width,
);
self.font_runs_pool.lock().push(font_runs);
layout
}
/// Layout the given line of text using a caller-provided content hash as the cache key.
///
/// This enables cache hits without materializing a contiguous `SharedString` for the text.
/// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
///
/// Contract (caller enforced):
/// - Same `text_hash` implies identical text content (collision risk accepted by caller).
/// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
pub fn layout_line_by_hash(
&self,
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &[TextRun],
force_width: Option<Pixels>,
materialize_text: impl FnOnce() -> SharedString,
) -> Arc<LineLayout> {
let mut last_run = None::<&TextRun>;
let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
font_runs.clear();
for run in runs.iter() {
let decoration_changed = if let Some(last_run) = last_run
&& last_run.color == run.color
&& last_run.underline == run.underline
&& last_run.strikethrough == run.strikethrough
// we do not consider differing background color relevant, as it does not affect glyphs
// && last_run.background_color == run.background_color
{
false
} else {
last_run = Some(run);
true
};
let font_id = self.resolve_font(&run.font);
if let Some(font_run) = font_runs.last_mut()
&& font_id == font_run.font_id
&& !decoration_changed
{
font_run.len += run.len;
} else {
font_runs.push(FontRun {
len: run.len,
font_id,
});
}
}
let layout = self.line_layout_cache.layout_line_by_hash(
text_hash,
text_len,
font_size,
&font_runs,
force_width,
materialize_text,
);
self.font_runs_pool.lock().push(font_runs);
layout
}
}
#[derive(Hash, Eq, PartialEq)]
@ -802,6 +996,11 @@ impl TextRun {
#[repr(C)]
pub struct GlyphId(pub u32);
/// Parameters for rendering a glyph, used as cache keys for raster bounds.
///
/// This struct identifies a specific glyph rendering configuration including
/// font, size, subpixel positioning, and scale factor. It's used to look up
/// cached raster bounds and sprite atlas entries.
#[derive(Clone, Debug, PartialEq)]
#[expect(missing_docs)]
pub struct RenderGlyphParams {

View file

@ -1,12 +1,24 @@
use crate::{
App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle,
TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px,
size,
App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result,
ShapedGlyph, ShapedRun, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window,
WrapBoundary, WrappedLineLayout, black, fill, point, px, size,
};
use derive_more::{Deref, DerefMut};
use smallvec::SmallVec;
use std::sync::Arc;
/// Pre-computed glyph data for efficient painting without per-glyph cache lookups.
///
/// This is produced by `ShapedLine::compute_glyph_raster_data` during prepaint
/// and consumed by `ShapedLine::paint_with_raster_data` during paint.
#[derive(Clone, Debug)]
pub struct GlyphRasterData {
/// The raster bounds for each glyph, in paint order.
pub bounds: Vec<Bounds<DevicePixels>>,
/// The render params for each glyph (needed for sprite atlas lookup).
pub params: Vec<RenderGlyphParams>,
}
/// Set the text decoration for a run of text.
#[derive(Debug, Clone)]
pub struct DecorationRun {
@ -44,6 +56,14 @@ impl ShapedLine {
self.layout.len
}
/// The width of the shaped line in pixels.
///
/// This is the glyph advance width computed by the text shaping system and is useful for
/// incrementally advancing a "pen" when painting multiple fragments on the same row.
pub fn width(&self) -> Pixels {
self.layout.width
}
/// Override the len, useful if you're rendering text a
/// as text b (e.g. rendering invisibles).
pub fn with_len(mut self, len: usize) -> Self {
@ -108,6 +128,120 @@ impl ShapedLine {
Ok(())
}
/// Split this shaped line at a byte index, returning `(prefix, suffix)`.
///
/// - `prefix` contains glyphs for bytes `[0, byte_index)` with original positions.
/// Its width equals the x-advance up to the split point.
/// - `suffix` contains glyphs for bytes `[byte_index, len)` with positions
/// shifted left so the first glyph starts at x=0, and byte indices rebased to 0.
/// - Decoration runs are partitioned at the boundary; a run that straddles it is
/// split into two with adjusted lengths.
/// - `font_size`, `ascent`, and `descent` are copied to both halves.
pub fn split_at(&self, byte_index: usize) -> (ShapedLine, ShapedLine) {
let x_offset = self.layout.x_for_index(byte_index);
// Partition glyph runs. A single run may contribute glyphs to both halves.
let mut left_runs = Vec::new();
let mut right_runs = Vec::new();
for run in &self.layout.runs {
let split_pos = run.glyphs.partition_point(|g| g.index < byte_index);
if split_pos > 0 {
left_runs.push(ShapedRun {
font_id: run.font_id,
glyphs: run.glyphs[..split_pos].to_vec(),
});
}
if split_pos < run.glyphs.len() {
let right_glyphs = run.glyphs[split_pos..]
.iter()
.map(|g| ShapedGlyph {
id: g.id,
position: point(g.position.x - x_offset, g.position.y),
index: g.index - byte_index,
is_emoji: g.is_emoji,
})
.collect();
right_runs.push(ShapedRun {
font_id: run.font_id,
glyphs: right_glyphs,
});
}
}
// Partition decoration runs. A run straddling the boundary is split into two.
let mut left_decorations = SmallVec::new();
let mut right_decorations = SmallVec::new();
let mut decoration_offset = 0u32;
let split_point = byte_index as u32;
for decoration in &self.decoration_runs {
let run_end = decoration_offset + decoration.len;
if run_end <= split_point {
left_decorations.push(decoration.clone());
} else if decoration_offset >= split_point {
right_decorations.push(decoration.clone());
} else {
let left_len = split_point - decoration_offset;
let right_len = run_end - split_point;
left_decorations.push(DecorationRun {
len: left_len,
color: decoration.color,
background_color: decoration.background_color,
underline: decoration.underline,
strikethrough: decoration.strikethrough,
});
right_decorations.push(DecorationRun {
len: right_len,
color: decoration.color,
background_color: decoration.background_color,
underline: decoration.underline,
strikethrough: decoration.strikethrough,
});
}
decoration_offset = run_end;
}
// Split text
let left_text = SharedString::new(self.text[..byte_index].to_string());
let right_text = SharedString::new(self.text[byte_index..].to_string());
let left_width = x_offset;
let right_width = self.layout.width - left_width;
let left = ShapedLine {
layout: Arc::new(LineLayout {
font_size: self.layout.font_size,
width: left_width,
ascent: self.layout.ascent,
descent: self.layout.descent,
runs: left_runs,
len: byte_index,
}),
text: left_text,
decoration_runs: left_decorations,
};
let right = ShapedLine {
layout: Arc::new(LineLayout {
font_size: self.layout.font_size,
width: right_width,
ascent: self.layout.ascent,
descent: self.layout.descent,
runs: right_runs,
len: self.layout.len - byte_index,
}),
text: right_text,
decoration_runs: right_decorations,
};
(left, right)
}
}
/// A line of text that has been shaped, decorated, and wrapped by the text layout system.
@ -594,3 +728,268 @@ fn aligned_origin_x(
TextAlign::Right => origin.x + align_width - line_width,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FontId, GlyphId};
/// Helper: build a ShapedLine from glyph descriptors without the platform text system.
/// Each glyph is described as (byte_index, x_position).
fn make_shaped_line(
text: &str,
glyphs: &[(usize, f32)],
width: f32,
decorations: &[DecorationRun],
) -> ShapedLine {
let shaped_glyphs: Vec<ShapedGlyph> = glyphs
.iter()
.map(|&(index, x)| ShapedGlyph {
id: GlyphId(0),
position: point(px(x), px(0.0)),
index,
is_emoji: false,
})
.collect();
ShapedLine {
layout: Arc::new(LineLayout {
font_size: px(16.0),
width: px(width),
ascent: px(12.0),
descent: px(4.0),
runs: vec![ShapedRun {
font_id: FontId(0),
glyphs: shaped_glyphs,
}],
len: text.len(),
}),
text: SharedString::new(text.to_string()),
decoration_runs: SmallVec::from(decorations.to_vec()),
}
}
#[test]
fn test_split_at_invariants() {
// Split "abcdef" at every possible byte index and verify structural invariants.
let line = make_shaped_line(
"abcdef",
&[
(0, 0.0),
(1, 10.0),
(2, 20.0),
(3, 30.0),
(4, 40.0),
(5, 50.0),
],
60.0,
&[],
);
for i in 0..=6 {
let (left, right) = line.split_at(i);
assert_eq!(
left.width() + right.width(),
line.width(),
"widths must sum at split={i}"
);
assert_eq!(
left.len() + right.len(),
line.len(),
"lengths must sum at split={i}"
);
assert_eq!(
format!("{}{}", left.text.as_ref(), right.text.as_ref()),
"abcdef",
"text must concatenate at split={i}"
);
assert_eq!(left.font_size, line.font_size, "font_size at split={i}");
assert_eq!(right.ascent, line.ascent, "ascent at split={i}");
assert_eq!(right.descent, line.descent, "descent at split={i}");
}
// Edge: split at 0 produces no left runs, full content on right
let (left, right) = line.split_at(0);
assert_eq!(left.runs.len(), 0);
assert_eq!(right.runs[0].glyphs.len(), 6);
// Edge: split at end produces full content on left, no right runs
let (left, right) = line.split_at(6);
assert_eq!(left.runs[0].glyphs.len(), 6);
assert_eq!(right.runs.len(), 0);
}
#[test]
fn test_split_at_glyph_rebasing() {
// Two font runs (simulating a font fallback boundary at byte 3):
// run A (FontId 0): glyphs at bytes 0,1,2 positions 0,10,20
// run B (FontId 1): glyphs at bytes 3,4,5 positions 30,40,50
// Successive splits simulate the incremental splitting done during wrap.
let line = ShapedLine {
layout: Arc::new(LineLayout {
font_size: px(16.0),
width: px(60.0),
ascent: px(12.0),
descent: px(4.0),
runs: vec![
ShapedRun {
font_id: FontId(0),
glyphs: vec![
ShapedGlyph {
id: GlyphId(0),
position: point(px(0.0), px(0.0)),
index: 0,
is_emoji: false,
},
ShapedGlyph {
id: GlyphId(0),
position: point(px(10.0), px(0.0)),
index: 1,
is_emoji: false,
},
ShapedGlyph {
id: GlyphId(0),
position: point(px(20.0), px(0.0)),
index: 2,
is_emoji: false,
},
],
},
ShapedRun {
font_id: FontId(1),
glyphs: vec![
ShapedGlyph {
id: GlyphId(0),
position: point(px(30.0), px(0.0)),
index: 3,
is_emoji: false,
},
ShapedGlyph {
id: GlyphId(0),
position: point(px(40.0), px(0.0)),
index: 4,
is_emoji: false,
},
ShapedGlyph {
id: GlyphId(0),
position: point(px(50.0), px(0.0)),
index: 5,
is_emoji: false,
},
],
},
],
len: 6,
}),
text: SharedString::new("abcdef".to_string()),
decoration_runs: SmallVec::new(),
};
// First split at byte 2 — mid-run in run A
let (first, remainder) = line.split_at(2);
assert_eq!(first.text.as_ref(), "ab");
assert_eq!(first.runs.len(), 1);
assert_eq!(first.runs[0].font_id, FontId(0));
// Remainder "cdef" should have two runs: tail of A (1 glyph) + all of B (3 glyphs)
assert_eq!(remainder.text.as_ref(), "cdef");
assert_eq!(remainder.runs.len(), 2);
assert_eq!(remainder.runs[0].font_id, FontId(0));
assert_eq!(remainder.runs[0].glyphs.len(), 1);
assert_eq!(remainder.runs[0].glyphs[0].index, 0);
assert_eq!(remainder.runs[0].glyphs[0].position.x, px(0.0));
assert_eq!(remainder.runs[1].font_id, FontId(1));
assert_eq!(remainder.runs[1].glyphs[0].index, 1);
assert_eq!(remainder.runs[1].glyphs[0].position.x, px(10.0));
// Second split at byte 2 within remainder — crosses the run boundary
let (second, final_part) = remainder.split_at(2);
assert_eq!(second.text.as_ref(), "cd");
assert_eq!(final_part.text.as_ref(), "ef");
assert_eq!(final_part.runs[0].glyphs[0].index, 0);
assert_eq!(final_part.runs[0].glyphs[0].position.x, px(0.0));
// Widths must sum across all three pieces
assert_eq!(
first.width() + second.width() + final_part.width(),
line.width()
);
}
#[test]
fn test_split_at_decorations() {
// Three decoration runs: red [0..2), green [2..5), blue [5..6).
// Split at byte 3 — red goes entirely left, green straddles, blue goes entirely right.
let red = Hsla {
h: 0.0,
s: 1.0,
l: 0.5,
a: 1.0,
};
let green = Hsla {
h: 0.3,
s: 1.0,
l: 0.5,
a: 1.0,
};
let blue = Hsla {
h: 0.6,
s: 1.0,
l: 0.5,
a: 1.0,
};
let line = make_shaped_line(
"abcdef",
&[
(0, 0.0),
(1, 10.0),
(2, 20.0),
(3, 30.0),
(4, 40.0),
(5, 50.0),
],
60.0,
&[
DecorationRun {
len: 2,
color: red,
background_color: None,
underline: None,
strikethrough: None,
},
DecorationRun {
len: 3,
color: green,
background_color: None,
underline: None,
strikethrough: None,
},
DecorationRun {
len: 1,
color: blue,
background_color: None,
underline: None,
strikethrough: None,
},
],
);
let (left, right) = line.split_at(3);
// Left: red(2) + green(1) — green straddled, left portion has len 1
assert_eq!(left.decoration_runs.len(), 2);
assert_eq!(left.decoration_runs[0].len, 2);
assert_eq!(left.decoration_runs[0].color, red);
assert_eq!(left.decoration_runs[1].len, 1);
assert_eq!(left.decoration_runs[1].color, green);
// Right: green(2) + blue(1) — green straddled, right portion has len 2
assert_eq!(right.decoration_runs.len(), 2);
assert_eq!(right.decoration_runs[0].len, 2);
assert_eq!(right.decoration_runs[0].color, green);
assert_eq!(right.decoration_runs[1].len, 1);
assert_eq!(right.decoration_runs[1].color, blue);
}
}

View file

@ -401,12 +401,25 @@ struct FrameCache {
wrapped_lines: FxHashMap<Arc<CacheKey>, Arc<WrappedLineLayout>>,
used_lines: Vec<Arc<CacheKey>>,
used_wrapped_lines: Vec<Arc<CacheKey>>,
// Content-addressable caches keyed by caller-provided text hash + layout params.
// These allow cache hits without materializing a contiguous `SharedString`.
//
// IMPORTANT: To support allocation-free lookups, we store these maps using a key type
// (`HashedCacheKeyRef`) that can be computed without building a contiguous `&str`/`SharedString`.
// On miss, we allocate once and store under an owned `HashedCacheKey`.
lines_by_hash: FxHashMap<Arc<HashedCacheKey>, Arc<LineLayout>>,
wrapped_lines_by_hash: FxHashMap<Arc<HashedCacheKey>, Arc<WrappedLineLayout>>,
used_lines_by_hash: Vec<Arc<HashedCacheKey>>,
used_wrapped_lines_by_hash: Vec<Arc<HashedCacheKey>>,
}
#[derive(Clone, Default)]
pub(crate) struct LineLayoutIndex {
lines_index: usize,
wrapped_lines_index: usize,
lines_by_hash_index: usize,
wrapped_lines_by_hash_index: usize,
}
impl LineLayoutCache {
@ -423,6 +436,8 @@ impl LineLayoutCache {
LineLayoutIndex {
lines_index: frame.used_lines.len(),
wrapped_lines_index: frame.used_wrapped_lines.len(),
lines_by_hash_index: frame.used_lines_by_hash.len(),
wrapped_lines_by_hash_index: frame.used_wrapped_lines_by_hash.len(),
}
}
@ -445,6 +460,24 @@ impl LineLayoutCache {
}
current_frame.used_wrapped_lines.push(key.clone());
}
for key in &previous_frame.used_lines_by_hash
[range.start.lines_by_hash_index..range.end.lines_by_hash_index]
{
if let Some((key, line)) = previous_frame.lines_by_hash.remove_entry(key) {
current_frame.lines_by_hash.insert(key, line);
}
current_frame.used_lines_by_hash.push(key.clone());
}
for key in &previous_frame.used_wrapped_lines_by_hash
[range.start.wrapped_lines_by_hash_index..range.end.wrapped_lines_by_hash_index]
{
if let Some((key, line)) = previous_frame.wrapped_lines_by_hash.remove_entry(key) {
current_frame.wrapped_lines_by_hash.insert(key, line);
}
current_frame.used_wrapped_lines_by_hash.push(key.clone());
}
}
pub fn truncate_layouts(&self, index: LineLayoutIndex) {
@ -453,6 +486,12 @@ impl LineLayoutCache {
current_frame
.used_wrapped_lines
.truncate(index.wrapped_lines_index);
current_frame
.used_lines_by_hash
.truncate(index.lines_by_hash_index);
current_frame
.used_wrapped_lines_by_hash
.truncate(index.wrapped_lines_by_hash_index);
}
pub fn finish_frame(&self) {
@ -463,6 +502,11 @@ impl LineLayoutCache {
curr_frame.wrapped_lines.clear();
curr_frame.used_lines.clear();
curr_frame.used_wrapped_lines.clear();
curr_frame.lines_by_hash.clear();
curr_frame.wrapped_lines_by_hash.clear();
curr_frame.used_lines_by_hash.clear();
curr_frame.used_wrapped_lines_by_hash.clear();
}
pub fn layout_wrapped_line<Text>(
@ -590,6 +634,165 @@ impl LineLayoutCache {
layout
}
}
/// Try to retrieve a previously-shaped line layout using a caller-provided content hash.
///
/// This is a *non-allocating* cache probe: it does not materialize any text. If the layout
/// is not already cached in either the current frame or previous frame, returns `None`.
///
/// Contract (caller enforced):
/// - Same `text_hash` implies identical text content (collision risk accepted by caller).
/// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
pub fn try_layout_line_by_hash(
&self,
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &[FontRun],
force_width: Option<Pixels>,
) -> Option<Arc<LineLayout>> {
let key_ref = HashedCacheKeyRef {
text_hash,
text_len,
font_size,
runs,
wrap_width: None,
force_width,
};
let current_frame = self.current_frame.read();
if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| {
HashedCacheKeyRef {
text_hash: key.text_hash,
text_len: key.text_len,
font_size: key.font_size,
runs: key.runs.as_slice(),
wrap_width: key.wrap_width,
force_width: key.force_width,
} == key_ref
}) {
return Some(layout.clone());
}
let previous_frame = self.previous_frame.lock();
if let Some((_, layout)) = previous_frame.lines_by_hash.iter().find(|(key, _)| {
HashedCacheKeyRef {
text_hash: key.text_hash,
text_len: key.text_len,
font_size: key.font_size,
runs: key.runs.as_slice(),
wrap_width: key.wrap_width,
force_width: key.force_width,
} == key_ref
}) {
return Some(layout.clone());
}
None
}
/// Layout a line of text using a caller-provided content hash as the cache key.
///
/// This enables cache hits without materializing a contiguous `SharedString` for `text`.
/// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
///
/// Contract (caller enforced):
/// - Same `text_hash` implies identical text content (collision risk accepted by caller).
/// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
pub fn layout_line_by_hash(
&self,
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &[FontRun],
force_width: Option<Pixels>,
materialize_text: impl FnOnce() -> SharedString,
) -> Arc<LineLayout> {
let key_ref = HashedCacheKeyRef {
text_hash,
text_len,
font_size,
runs,
wrap_width: None,
force_width,
};
// Fast path: already cached (no allocation).
let current_frame = self.current_frame.upgradable_read();
if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| {
HashedCacheKeyRef {
text_hash: key.text_hash,
text_len: key.text_len,
font_size: key.font_size,
runs: key.runs.as_slice(),
wrap_width: key.wrap_width,
force_width: key.force_width,
} == key_ref
}) {
return layout.clone();
}
let mut current_frame = RwLockUpgradableReadGuard::upgrade(current_frame);
// Try to reuse from previous frame without allocating; do a linear scan to find a matching key.
// (We avoid `drain()` here because it would eagerly move all entries.)
let mut previous_frame = self.previous_frame.lock();
if let Some(existing_key) = previous_frame
.used_lines_by_hash
.iter()
.find(|key| {
HashedCacheKeyRef {
text_hash: key.text_hash,
text_len: key.text_len,
font_size: key.font_size,
runs: key.runs.as_slice(),
wrap_width: key.wrap_width,
force_width: key.force_width,
} == key_ref
})
.cloned()
{
if let Some((key, layout)) = previous_frame.lines_by_hash.remove_entry(&existing_key) {
current_frame
.lines_by_hash
.insert(key.clone(), layout.clone());
current_frame.used_lines_by_hash.push(key);
return layout;
}
}
let text = materialize_text();
let mut layout = self
.platform_text_system
.layout_line(&text, font_size, runs);
if let Some(force_width) = force_width {
let mut glyph_pos = 0;
for run in layout.runs.iter_mut() {
for glyph in run.glyphs.iter_mut() {
if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) {
glyph.position.x = glyph_pos * force_width;
}
glyph_pos += 1;
}
}
}
let key = Arc::new(HashedCacheKey {
text_hash,
text_len,
font_size,
runs: SmallVec::from(runs),
wrap_width: None,
force_width,
});
let layout = Arc::new(layout);
current_frame
.lines_by_hash
.insert(key.clone(), layout.clone());
current_frame.used_lines_by_hash.push(key);
layout
}
}
/// A run of text with a single font.
@ -622,12 +825,80 @@ struct CacheKeyRef<'a> {
force_width: Option<Pixels>,
}
#[derive(Clone, Debug)]
struct HashedCacheKey {
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: SmallVec<[FontRun; 1]>,
wrap_width: Option<Pixels>,
force_width: Option<Pixels>,
}
#[derive(Copy, Clone)]
struct HashedCacheKeyRef<'a> {
text_hash: u64,
text_len: usize,
font_size: Pixels,
runs: &'a [FontRun],
wrap_width: Option<Pixels>,
force_width: Option<Pixels>,
}
impl PartialEq for dyn AsCacheKeyRef + '_ {
fn eq(&self, other: &dyn AsCacheKeyRef) -> bool {
self.as_cache_key_ref() == other.as_cache_key_ref()
}
}
impl PartialEq for HashedCacheKey {
fn eq(&self, other: &Self) -> bool {
self.text_hash == other.text_hash
&& self.text_len == other.text_len
&& self.font_size == other.font_size
&& self.runs.as_slice() == other.runs.as_slice()
&& self.wrap_width == other.wrap_width
&& self.force_width == other.force_width
}
}
impl Eq for HashedCacheKey {}
impl Hash for HashedCacheKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.text_hash.hash(state);
self.text_len.hash(state);
self.font_size.hash(state);
self.runs.as_slice().hash(state);
self.wrap_width.hash(state);
self.force_width.hash(state);
}
}
impl PartialEq for HashedCacheKeyRef<'_> {
fn eq(&self, other: &Self) -> bool {
self.text_hash == other.text_hash
&& self.text_len == other.text_len
&& self.font_size == other.font_size
&& self.runs == other.runs
&& self.wrap_width == other.wrap_width
&& self.force_width == other.force_width
}
}
impl Eq for HashedCacheKeyRef<'_> {}
impl Hash for HashedCacheKeyRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.text_hash.hash(state);
self.text_len.hash(state);
self.font_size.hash(state);
self.runs.hash(state);
self.wrap_width.hash(state);
self.force_width.hash(state);
}
}
impl Eq for dyn AsCacheKeyRef + '_ {}
impl Hash for dyn AsCacheKeyRef + '_ {

View file

@ -566,6 +566,10 @@ impl HitboxId {
///
/// See [`Hitbox::is_hovered`] for details.
pub fn is_hovered(self, window: &Window) -> bool {
// If this hitbox has captured the pointer, it's always considered hovered
if window.captured_hitbox == Some(self) {
return true;
}
let hit_test = &window.mouse_hit_test;
for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) {
if self == *id {
@ -822,6 +826,11 @@ impl Frame {
self.tab_stops.clear();
self.focus = None;
#[cfg(any(test, feature = "test-support"))]
{
self.debug_bounds.clear();
}
#[cfg(any(feature = "inspector", debug_assertions))]
{
self.next_inspector_instance_ids.clear();
@ -952,6 +961,9 @@ pub struct Window {
pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>,
prompt: Option<RenderablePromptHandle>,
pub(crate) client_inset: Option<Pixels>,
/// The hitbox that has captured the pointer, if any.
/// While captured, mouse events route to this hitbox regardless of hit testing.
captured_hitbox: Option<HitboxId>,
#[cfg(any(feature = "inspector", debug_assertions))]
inspector: Option<Entity<Inspector>>,
}
@ -1439,6 +1451,7 @@ impl Window {
prompt: None,
client_inset: None,
image_cache_stack: Vec::new(),
captured_hitbox: None,
#[cfg(any(feature = "inspector", debug_assertions))]
inspector: None,
})
@ -1888,7 +1901,12 @@ impl Window {
})
}
fn bounds_changed(&mut self, cx: &mut App) {
/// Notify the window that its bounds have changed.
///
/// This updates internal state like `viewport_size` and `scale_factor` from
/// the platform window, then notifies observers. Normally called automatically
/// by the platform's resize callback, but exposed publicly for test infrastructure.
pub fn bounds_changed(&mut self, cx: &mut App) {
self.scale_factor = self.platform_window.scale_factor();
self.viewport_size = self.platform_window.content_size();
self.display_id = self.platform_window.display().map(|display| display.id());
@ -2144,6 +2162,26 @@ impl Window {
self.mouse_position
}
/// Captures the pointer for the given hitbox. While captured, all mouse move and mouse up
/// events will be routed to listeners that check this hitbox's `is_hovered` status,
/// regardless of actual hit testing. This enables drag operations that continue
/// even when the pointer moves outside the element's bounds.
///
/// The capture is automatically released on mouse up.
pub fn capture_pointer(&mut self, hitbox_id: HitboxId) {
self.captured_hitbox = Some(hitbox_id);
}
/// Releases any active pointer capture.
pub fn release_pointer(&mut self) {
self.captured_hitbox = None;
}
/// Returns the hitbox that has captured the pointer, if any.
pub fn captured_hitbox(&self) -> Option<HitboxId> {
self.captured_hitbox
}
/// The current state of the keyboard's modifiers
pub fn modifiers(&self) -> Modifiers {
self.modifiers
@ -3295,6 +3333,100 @@ impl Window {
Ok(())
}
/// Paints a monochrome glyph with pre-computed raster bounds.
///
/// This is faster than `paint_glyph` because it skips the per-glyph cache lookup.
/// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint.
pub fn paint_glyph_with_raster_bounds(
&mut self,
origin: Point<Pixels>,
_font_id: FontId,
_glyph_id: GlyphId,
_font_size: Pixels,
color: Hsla,
raster_bounds: Bounds<DevicePixels>,
params: &RenderGlyphParams,
) -> Result<()> {
self.invalidator.debug_assert_paint();
let element_opacity = self.element_opacity();
let scale_factor = self.scale_factor();
let glyph_origin = origin.scale(scale_factor);
if !raster_bounds.is_zero() {
let tile = self
.sprite_atlas
.get_or_insert_with(&params.clone().into(), &mut || {
let (size, bytes) = self.text_system().rasterize_glyph(params)?;
Ok(Some((size, Cow::Owned(bytes))))
})?
.expect("Callback above only errors or returns Some");
let bounds = Bounds {
origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into),
size: tile.bounds.size.map(Into::into),
};
let content_mask = self.content_mask().scale(scale_factor);
self.next_frame.scene.insert_primitive(MonochromeSprite {
order: 0,
pad: 0,
bounds,
content_mask,
color: color.opacity(element_opacity),
tile,
transformation: TransformationMatrix::unit(),
});
}
Ok(())
}
/// Paints an emoji glyph with pre-computed raster bounds.
///
/// This is faster than `paint_emoji` because it skips the per-glyph cache lookup.
/// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint.
pub fn paint_emoji_with_raster_bounds(
&mut self,
origin: Point<Pixels>,
_font_id: FontId,
_glyph_id: GlyphId,
_font_size: Pixels,
raster_bounds: Bounds<DevicePixels>,
params: &RenderGlyphParams,
) -> Result<()> {
self.invalidator.debug_assert_paint();
let scale_factor = self.scale_factor();
let glyph_origin = origin.scale(scale_factor);
if !raster_bounds.is_zero() {
let tile = self
.sprite_atlas
.get_or_insert_with(&params.clone().into(), &mut || {
let (size, bytes) = self.text_system().rasterize_glyph(params)?;
Ok(Some((size, Cow::Owned(bytes))))
})?
.expect("Callback above only errors or returns Some");
let bounds = Bounds {
origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into),
size: tile.bounds.size.map(Into::into),
};
let content_mask = self.content_mask().scale(scale_factor);
let opacity = self.element_opacity();
self.next_frame.scene.insert_primitive(PolychromeSprite {
order: 0,
pad: 0,
grayscale: false,
bounds,
corner_radii: Default::default(),
content_mask,
tile,
opacity,
});
}
Ok(())
}
fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool {
if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque {
return false;
@ -4063,6 +4195,11 @@ impl Window {
self.refresh();
}
}
// Auto-release pointer capture on mouse up
if event.is::<MouseUpEvent>() && self.captured_hitbox.is_some() {
self.captured_hitbox = None;
}
}
fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) {

View file

@ -110,10 +110,12 @@ impl InstanceBufferPool {
pub(crate) struct MetalRenderer {
device: metal::Device,
layer: metal::MetalLayer,
layer: Option<metal::MetalLayer>,
is_apple_gpu: bool,
is_unified_memory: bool,
presents_with_transaction: bool,
/// For headless rendering, tracks whether output should be opaque
opaque: bool,
command_queue: CommandQueue,
paths_rasterization_pipeline_state: metal::RenderPipelineState,
path_sprites_pipeline_state: metal::RenderPipelineState,
@ -142,26 +144,9 @@ pub struct PathRasterizationVertex {
}
impl MetalRenderer {
/// Creates a new MetalRenderer with a CAMetalLayer for window-based rendering.
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>, transparent: bool) -> Self {
// Prefer lowpower integrated GPUs on Intel Mac. On Apple
// Silicon, there is only ever one GPU, so this is equivalent to
// `metal::Device::system_default()`.
let device = if let Some(d) = metal::Device::all()
.into_iter()
.min_by_key(|d| (d.is_removable(), !d.is_low_power()))
{
d
} else {
// For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689
// In that case, we fall back to the system default device.
log::error!(
"Unable to enumerate Metal devices; attempting to use system default device"
);
metal::Device::system_default().unwrap_or_else(|| {
log::error!("unable to access a compatible graphics device");
std::process::exit(1);
})
};
let device = Self::create_device();
let layer = metal::MetalLayer::new();
layer.set_device(&device);
@ -182,6 +167,48 @@ impl MetalRenderer {
| AutoresizingMask::HEIGHT_SIZABLE
];
}
Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool)
}
/// Creates a new headless MetalRenderer for offscreen rendering without a window.
///
/// This renderer can render scenes to images without requiring a CAMetalLayer,
/// window, or AppKit. Use `render_scene_to_image()` to render scenes.
#[cfg(any(test, feature = "test-support"))]
pub fn new_headless(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>) -> Self {
let device = Self::create_device();
Self::new_internal(device, None, true, instance_buffer_pool)
}
fn create_device() -> metal::Device {
// Prefer lowpower integrated GPUs on Intel Mac. On Apple
// Silicon, there is only ever one GPU, so this is equivalent to
// `metal::Device::system_default()`.
if let Some(d) = metal::Device::all()
.into_iter()
.min_by_key(|d| (d.is_removable(), !d.is_low_power()))
{
d
} else {
// For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689
// In that case, we fall back to the system default device.
log::error!(
"Unable to enumerate Metal devices; attempting to use system default device"
);
metal::Device::system_default().unwrap_or_else(|| {
log::error!("unable to access a compatible graphics device");
std::process::exit(1);
})
}
}
fn new_internal(
device: metal::Device,
layer: Option<metal::MetalLayer>,
opaque: bool,
instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>,
) -> Self {
#[cfg(feature = "runtime_shaders")]
let library = device
.new_library_with_source(&SHADERS_SOURCE_FILE, &metal::CompileOptions::new())
@ -303,6 +330,7 @@ impl MetalRenderer {
presents_with_transaction: false,
is_apple_gpu,
is_unified_memory,
opaque,
command_queue,
paths_rasterization_pipeline_state,
path_sprites_pipeline_state,
@ -322,12 +350,15 @@ impl MetalRenderer {
}
}
pub fn layer(&self) -> &metal::MetalLayerRef {
&self.layer
pub fn layer(&self) -> Option<&metal::MetalLayerRef> {
self.layer.as_ref().map(|l| l.as_ref())
}
pub fn layer_ptr(&self) -> *mut CAMetalLayer {
self.layer.as_ptr()
self.layer
.as_ref()
.map(|l| l.as_ptr())
.unwrap_or(ptr::null_mut())
}
pub fn sprite_atlas(&self) -> &Arc<MetalAtlas> {
@ -336,26 +367,25 @@ impl MetalRenderer {
pub fn set_presents_with_transaction(&mut self, presents_with_transaction: bool) {
self.presents_with_transaction = presents_with_transaction;
self.layer
.set_presents_with_transaction(presents_with_transaction);
if let Some(layer) = &self.layer {
layer.set_presents_with_transaction(presents_with_transaction);
}
}
pub fn update_drawable_size(&mut self, size: Size<DevicePixels>) {
let size = NSSize {
width: size.width.0 as f64,
height: size.height.0 as f64,
};
unsafe {
let _: () = msg_send![
self.layer(),
setDrawableSize: size
];
if let Some(layer) = &self.layer {
let ns_size = NSSize {
width: size.width.0 as f64,
height: size.height.0 as f64,
};
unsafe {
let _: () = msg_send![
layer.as_ref(),
setDrawableSize: ns_size
];
}
}
let device_pixels_size = Size {
width: DevicePixels(size.width as i32),
height: DevicePixels(size.height as i32),
};
self.update_path_intermediate_textures(device_pixels_size);
self.update_path_intermediate_textures(size);
}
fn update_path_intermediate_textures(&mut self, size: Size<DevicePixels>) {
@ -396,8 +426,11 @@ impl MetalRenderer {
}
}
pub fn update_transparency(&self, transparent: bool) {
self.layer.set_opaque(!transparent);
pub fn update_transparency(&mut self, transparent: bool) {
self.opaque = !transparent;
if let Some(layer) = &self.layer {
layer.set_opaque(!transparent);
}
}
pub fn destroy(&self) {
@ -405,7 +438,15 @@ impl MetalRenderer {
}
pub fn draw(&mut self, scene: &Scene) {
let layer = self.layer.clone();
let layer = match &self.layer {
Some(l) => l.clone(),
None => {
log::error!(
"draw() called on headless renderer - use render_scene_to_image() instead"
);
return;
}
};
let viewport_size = layer.drawable_size();
let viewport_size: Size<DevicePixels> = size(
(viewport_size.width.ceil() as i32).into(),
@ -476,9 +517,15 @@ impl MetalRenderer {
/// Renders the scene to a texture and returns the pixel data as an RGBA image.
/// This does not present the frame to screen - useful for visual testing
/// where we want to capture what would be rendered without displaying it.
///
/// Note: This requires a layer-backed renderer. For headless rendering,
/// use `render_scene_to_image()` instead.
#[cfg(any(test, feature = "test-support"))]
pub fn render_to_image(&mut self, scene: &Scene) -> Result<RgbaImage> {
let layer = self.layer.clone();
let layer = self
.layer
.clone()
.ok_or_else(|| anyhow::anyhow!("render_to_image requires a layer-backed renderer"))?;
let viewport_size = layer.drawable_size();
let viewport_size: Size<DevicePixels> = size(
(viewport_size.width.ceil() as i32).into(),
@ -567,21 +614,146 @@ impl MetalRenderer {
}
}
/// Renders a scene to an image without requiring a window or CAMetalLayer.
///
/// This is the primary method for headless rendering. It creates an offscreen
/// texture, renders the scene to it, and returns the pixel data as an RGBA image.
#[cfg(any(test, feature = "test-support"))]
pub fn render_scene_to_image(
&mut self,
scene: &Scene,
size: Size<DevicePixels>,
) -> Result<RgbaImage> {
if size.width.0 <= 0 || size.height.0 <= 0 {
anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size);
}
// Update path intermediate textures for this size
self.update_path_intermediate_textures(size);
// Create an offscreen texture as render target
let texture_descriptor = metal::TextureDescriptor::new();
texture_descriptor.set_width(size.width.0 as u64);
texture_descriptor.set_height(size.height.0 as u64);
texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
texture_descriptor
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed);
let target_texture = self.device.new_texture(&texture_descriptor);
loop {
let mut instance_buffer = self
.instance_buffer_pool
.lock()
.acquire(&self.device, self.is_unified_memory);
let command_buffer =
self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size);
match command_buffer {
Ok(command_buffer) => {
let instance_buffer_pool = self.instance_buffer_pool.clone();
let instance_buffer = Cell::new(Some(instance_buffer));
let block = ConcreteBlock::new(move |_| {
if let Some(instance_buffer) = instance_buffer.take() {
instance_buffer_pool.lock().release(instance_buffer);
}
});
let block = block.copy();
command_buffer.add_completed_handler(&block);
// On discrete GPUs (non-unified memory), Managed textures
// require an explicit blit synchronize before the CPU can
// read back the rendered data. Without this, get_bytes
// returns stale zeros.
if !self.is_unified_memory {
let blit = command_buffer.new_blit_command_encoder();
blit.synchronize_resource(&target_texture);
blit.end_encoding();
}
// Commit and wait for completion
command_buffer.commit();
command_buffer.wait_until_completed();
// Read pixels from the texture
let width = size.width.0 as u32;
let height = size.height.0 as u32;
let bytes_per_row = width as usize * 4;
let buffer_size = height as usize * bytes_per_row;
let mut pixels = vec![0u8; buffer_size];
let region = metal::MTLRegion {
origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
size: metal::MTLSize {
width: width as u64,
height: height as u64,
depth: 1,
},
};
target_texture.get_bytes(
pixels.as_mut_ptr() as *mut std::ffi::c_void,
bytes_per_row as u64,
region,
0,
);
// Convert BGRA to RGBA (swap B and R channels)
for chunk in pixels.chunks_exact_mut(4) {
chunk.swap(0, 2);
}
return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
anyhow::anyhow!("Failed to create RgbaImage from pixel data")
});
}
Err(err) => {
log::error!(
"failed to render: {}. retrying with larger instance buffer size",
err
);
let mut instance_buffer_pool = self.instance_buffer_pool.lock();
let buffer_size = instance_buffer_pool.buffer_size;
if buffer_size >= 256 * 1024 * 1024 {
anyhow::bail!("instance buffer size grew too large: {}", buffer_size);
}
instance_buffer_pool.reset(buffer_size * 2);
log::info!(
"increased instance buffer size to {}",
instance_buffer_pool.buffer_size
);
}
}
}
}
fn draw_primitives(
&mut self,
scene: &Scene,
instance_buffer: &mut InstanceBuffer,
drawable: &metal::MetalDrawableRef,
viewport_size: Size<DevicePixels>,
) -> Result<metal::CommandBuffer> {
self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size)
}
fn draw_primitives_to_texture(
&mut self,
scene: &Scene,
instance_buffer: &mut InstanceBuffer,
texture: &metal::TextureRef,
viewport_size: Size<DevicePixels>,
) -> Result<metal::CommandBuffer> {
let command_queue = self.command_queue.clone();
let command_buffer = command_queue.new_command_buffer();
let alpha = if self.layer.is_opaque() { 1. } else { 0. };
let alpha = if self.opaque { 1. } else { 0. };
let mut instance_offset = 0;
let mut command_encoder = new_command_encoder(
let mut command_encoder = new_command_encoder_for_texture(
command_buffer,
drawable,
texture,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
@ -617,9 +789,9 @@ impl MetalRenderer {
command_buffer,
);
command_encoder = new_command_encoder(
command_encoder = new_command_encoder_for_texture(
command_buffer,
drawable,
texture,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
@ -1309,9 +1481,9 @@ impl MetalRenderer {
}
}
fn new_command_encoder<'a>(
fn new_command_encoder_for_texture<'a>(
command_buffer: &'a metal::CommandBufferRef,
drawable: &'a metal::MetalDrawableRef,
texture: &'a metal::TextureRef,
viewport_size: Size<DevicePixels>,
configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef),
) -> &'a metal::RenderCommandEncoderRef {
@ -1320,7 +1492,7 @@ fn new_command_encoder<'a>(
.color_attachments()
.object_at(0)
.unwrap();
color_attachment.set_texture(Some(drawable.texture()));
color_attachment.set_texture(Some(texture));
color_attachment.set_store_action(metal::MTLStoreAction::Store);
configure_color_attachment(color_attachment);
@ -1506,3 +1678,32 @@ pub struct SurfaceBounds {
pub bounds: Bounds<ScaledPixels>,
pub content_mask: ContentMask<ScaledPixels>,
}
#[cfg(any(test, feature = "test-support"))]
pub struct MetalHeadlessRenderer {
renderer: MetalRenderer,
}
#[cfg(any(test, feature = "test-support"))]
impl MetalHeadlessRenderer {
pub fn new() -> Self {
let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default()));
let renderer = MetalRenderer::new_headless(instance_buffer_pool);
Self { renderer }
}
}
#[cfg(any(test, feature = "test-support"))]
impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer {
fn render_scene_to_image(
&mut self,
scene: &Scene,
size: Size<DevicePixels>,
) -> anyhow::Result<image::RgbaImage> {
self.renderer.render_scene_to_image(scene, size)
}
fn sprite_atlas(&self) -> Arc<dyn gpui::PlatformAtlas> {
self.renderer.sprite_atlas().clone()
}
}

View file

@ -53,7 +53,8 @@ use crate::open_type::apply_features_and_fallbacks;
#[allow(non_upper_case_globals)]
const kCGImageAlphaOnly: u32 = 7;
pub(crate) struct MacTextSystem(RwLock<MacTextSystemState>);
/// macOS text system using CoreText for font shaping.
pub struct MacTextSystem(RwLock<MacTextSystemState>);
#[derive(Clone, PartialEq, Eq, Hash)]
struct FontKey {
@ -73,7 +74,8 @@ struct MacTextSystemState {
}
impl MacTextSystem {
pub(crate) fn new() -> Self {
/// Create a new MacTextSystem.
pub fn new() -> Self {
Self(RwLock::new(MacTextSystemState {
memory_source: MemSource::empty(),
system_source: SystemSource::new(),

View file

@ -2067,11 +2067,13 @@ fn update_window_scale_factor(window_state: &Arc<Mutex<MacWindowState>>) {
let scale_factor = lock.scale_factor();
let size = lock.content_size();
let drawable_size = size.to_device_pixels(scale_factor);
unsafe {
let _: () = msg_send![
lock.renderer.layer(),
setContentsScale: scale_factor as f64
];
if let Some(layer) = lock.renderer.layer() {
unsafe {
let _: () = msg_send![
layer,
setContentsScale: scale_factor as f64
];
}
}
lock.renderer.update_drawable_size(drawable_size);

View file

@ -59,6 +59,22 @@ pub fn current_platform(headless: bool) -> Rc<dyn Platform> {
}
}
/// Returns a new [`HeadlessRenderer`] for the current platform, if available.
#[cfg(feature = "test-support")]
pub fn current_headless_renderer() -> Option<Box<dyn gpui::PlatformHeadlessRenderer>> {
#[cfg(target_os = "macos")]
{
Some(Box::new(
gpui_macos::metal_renderer::MetalHeadlessRenderer::new(),
))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;