GPUI updates (#51415)

Conrad Irwin , Nathan Sobo , John Tur , Agus Zubiaga , and Antonio Scandurra created

- **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>

Change summary

crates/gpui/src/app.rs                      |   8 
crates/gpui/src/app/headless_app_context.rs | 267 ++++++++++
crates/gpui/src/app/test_app.rs             | 607 +++++++++++++++++++++++
crates/gpui/src/app/test_context.rs         |  27 +
crates/gpui/src/color.rs                    |   9 
crates/gpui/src/executor.rs                 |   7 
crates/gpui/src/platform.rs                 |  25 
crates/gpui/src/platform/test/dispatcher.rs |  15 
crates/gpui/src/platform/test/platform.rs   |  36 +
crates/gpui/src/platform/test/window.rs     |  35 +
crates/gpui/src/scene.rs                    |   4 
crates/gpui/src/text_system.rs              | 203 +++++++
crates/gpui/src/text_system/line.rs         | 405 +++++++++++++++
crates/gpui/src/text_system/line_layout.rs  | 271 ++++++++++
crates/gpui/src/window.rs                   | 139 +++++
crates/gpui_macos/src/metal_renderer.rs     | 303 +++++++++-
crates/gpui_macos/src/text_system.rs        |   6 
crates/gpui_macos/src/window.rs             |  12 
crates/gpui_platform/src/gpui_platform.rs   |  16 
19 files changed, 2,315 insertions(+), 80 deletions(-)

Detailed changes

crates/gpui/src/app.rs πŸ”—

@@ -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;

crates/gpui/src/app/headless_app_context.rs πŸ”—

@@ -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)
+    }
+}

crates/gpui/src/app/test_app.rs πŸ”—

@@ -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");
+        });
+    }
+}

crates/gpui/src/app/test_context.rs πŸ”—

@@ -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();

crates/gpui/src/color.rs πŸ”—

@@ -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>

crates/gpui/src/executor.rs πŸ”—

@@ -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>

crates/gpui/src/platform.rs πŸ”—

@@ -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;
 }

crates/gpui/src/platform/test/dispatcher.rs πŸ”—

@@ -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) {

crates/gpui/src/platform/test/platform.rs πŸ”—

@@ -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))
     }

crates/gpui/src/platform/test/window.rs πŸ”—

@@ -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)
     }

crates/gpui/src/scene.rs πŸ”—

@@ -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>,

crates/gpui/src/text_system.rs πŸ”—

@@ -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 {

crates/gpui/src/text_system/line.rs πŸ”—

@@ -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);
+    }
+}

crates/gpui/src/text_system/line_layout.rs πŸ”—

@@ -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 + '_ {

crates/gpui/src/window.rs πŸ”—

@@ -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) {

crates/gpui_macos/src/metal_renderer.rs πŸ”—

@@ -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 low‐power 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 low‐power 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()
+    }
+}

crates/gpui_macos/src/text_system.rs πŸ”—

@@ -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(),

crates/gpui_macos/src/window.rs πŸ”—

@@ -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);

crates/gpui_platform/src/gpui_platform.rs πŸ”—

@@ -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::*;