Detailed changes
@@ -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;
@@ -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)
+ }
+}
@@ -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");
+ });
+ }
+}
@@ -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();
@@ -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>
@@ -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>
@@ -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;
}
@@ -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) {
@@ -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))
}
@@ -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)
}
@@ -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>,
@@ -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 {
@@ -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);
+ }
+}
@@ -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 + '_ {
@@ -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(¶ms.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(¶ms.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) {
@@ -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()
+ }
+}
@@ -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(),
@@ -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);
@@ -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::*;