mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
GPUI updates (#51415)
- **Fix race condition in test_collaborating_with_completion** - **WIP: Integrate scheduler crate into GPUI TestDispatcher** - **WIP: scheduler integration debugging** - **Fix formatting** - **Unify RunnableMeta and add execution tracking to TestScheduler** - **Remove unused execution tracking from TestScheduler and TestDispatcher** - **Add is_ready() to GPUI Task for API parity with scheduler** - **Eliminate RunnableVariant::Compat - all runnables now have source location metadata** - **Update integration plans to reflect completed phases** - **Simplify RunnableVariant to type alias** - **Delegate TestDispatcher task queues to TestScheduler (Phase 2b)** - **Remove waiting_hint/waiting_backtrace and debug logging from TestDispatcher** - **Remove wrapper methods from TestDispatcher - access scheduler() directly** - **Update integration plan with complete state and instructions for full scheduler migration** - **Use scheduler's native timer() and simplify TestDispatcher** - **Fix rng() usage to lock mutex, update plan with SharedRng wrapper** - **Add SharedRng wrapper for ergonomic random number generation** - **Update plan: mark Phase 1 (SharedRng) as complete** - **Update scheduler integration plan with Phase 2 investigation notes** - **Phase 3: Delegate simulate_random_delay to scheduler.yield_random()** - **Phase 4: Remove TaskLabel** - **Phase 5 (WIP): Simplify block_internal and remove unparkers** - **Phase 5 Complete: Scheduler integration finished** - **Update integration plan with code review findings** - **Phase 6 & 7: Restore realtime priority support and delete dead code** - **Add TestApp and TestAppWindow for cleaner GPUI testing** - **Fix formatting across the branch** - **Fix Linux build: add explicit type annotation and rename probability() to weight()** - **Add TestApp and TestAppWindow for cleaner GPUI testing** - **Rename TestAppWindow to TestWindow, internal TestWindow to TestPlatformWindow** - **Remove unused RunnableVariant imports on Linux** - **Add STATUS.md for next agent** - **Run cargo fmt** - **Use per-app element arena only and scope test draws** - **Fix collab tests for scheduler timing and ordering** - **Store element arena on App and route element allocations through draw scope** - **Fix TestScheduler lock ordering between rng and state** - **Fix inlay hints test by explicitly triggering refresh after viewport setup** - **Add scheduler integration regression risk analysis doc** - **Fix tests: avoid caching Entity in global OnceLock for Codestral API key** - **Document learned weak point: global cached Entity handles break across App contexts** - **Add scheduler regression test for block_with_timeout continuation and explicit time advancement** - **Document TestScheduler timeout tick budget behavior and explicit time advancement guidance** - **Add test asserting realtime priority spawns panic under TestDispatcher** - **Document realtime priority determinism contract in tests** - **Remove realtime priority until we have a concrete use case (cc @localcc)** - **Update STATUS for scheduler integration decisions and realtime priority removal** - **Fix prettier docs and clippy in scheduler tests** - **Remove unused imports from Windows dispatcher** - **WIP: scheduler integration debugging + agent terminal diagnostics** - **Update scheduler integration status** - **Remove temporary planning docs, consolidate into scheduler integration doc** - **Remove unrelated changes from scheduler integration** - **Fix clippy errors** - **Add STATUS.md with debugging instructions for Linux/Windows hang** - **WIP: local changes needed by ex** - **Add pointer capture API for stable drag handling** - **Add pointer capture API for stable drag handling** - **chore: update generated cargo manifests** - **gpui: Expose ShapedLine::width() for pen advancement** - **Remove git2 usage from util test.rs** - **Store DiagnosticQuad bounds in logical Pixels** - **WIP: executor and test_app changes for scheduler integration** - **Expose font APIs publicly** - **gpui: add typed diagnostics and record_diagnostic API** - **WIP: gpui test window diagnostics changes** - **Add LineCacheKey trait and shape_line_cached API for content-addressable shaping** - **Fix RenderGlyphParams field additions for Ex compatibility** - **Add doc comment for recommended_rendering_mode, fix formatting** - **Add scheduler_executor() method for Ex compatibility** - **Fix TestWindow -> TestPlatformWindow in test_context.rs** - **Add headless metal renderer and window focus improvements** - **Fix double borrow in TestWindow::simulate_resize** - **Fix cbindgen panic: remove default type parameter from Diagnostic<T>** - **Implement AppContext for HeadlessMetalAppContext** - **Missing trait impls** - **Add ShapedLine::split_at and eliminate re-shaping in soft wraps** - **Add handoff doc for platform-neutral-tests merge** - **Remove ex-only test infrastructure before merging main** - **Add cross-platform HeadlessAppContext with pluggable text system** - **Export platform_text_system() from gpui_windows for cross-platform tests** - **Restore TestApp/TestAppWindow with pluggable text system support** - **Add TestApp::open_window_sized for tests that need specific window dimensions** - **Fix some warnings** - **Fixes** - **Add a platform-neutral headless renderer interface** - **Synchronize Managed texture before CPU readback on discrete GPUs** - **Allow creating TestDispatcher with custom scheduler** Release Notes: - N/A --------- Co-authored-by: Nathan Sobo <nathan@zed.dev> Co-authored-by: John Tur <john-tur@outlook.com> Co-authored-by: Agus Zubiaga <agus@zed.dev> Co-authored-by: Antonio Scandurra <me@as-cii.com>
This commit is contained in:
parent
ad1e82e9e2
commit
b32067d248
19 changed files with 2314 additions and 79 deletions
|
|
@ -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;
|
||||
|
|
|
|||
267
crates/gpui/src/app/headless_app_context.rs
Normal file
267
crates/gpui/src/app/headless_app_context.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
//! Cross-platform headless app context for tests that need real text shaping.
|
||||
//!
|
||||
//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral
|
||||
//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem`
|
||||
//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get
|
||||
//! accurate glyph measurements while keeping everything else deterministic.
|
||||
//!
|
||||
//! Optionally, a renderer factory can be provided to enable real GPU rendering
|
||||
//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`].
|
||||
|
||||
use crate::{
|
||||
AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds,
|
||||
Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer,
|
||||
PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem,
|
||||
Window, WindowBounds, WindowHandle, WindowOptions,
|
||||
app::{GpuiBorrow, GpuiMode},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use image::RgbaImage;
|
||||
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
/// A cross-platform headless app context for tests that need real text shaping.
|
||||
///
|
||||
/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses
|
||||
/// `TestPlatform` for deterministic scheduling and accepts a pluggable
|
||||
/// `PlatformTextSystem` so tests get real glyph measurements.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback"));
|
||||
/// let mut cx = HeadlessAppContext::with_platform(
|
||||
/// text_system,
|
||||
/// Arc::new(Assets),
|
||||
/// || gpui_platform::current_headless_renderer(),
|
||||
/// );
|
||||
/// ```
|
||||
pub struct HeadlessAppContext {
|
||||
/// The underlying app cell.
|
||||
pub app: Rc<AppCell>,
|
||||
/// The background executor for running async tasks.
|
||||
pub background_executor: BackgroundExecutor,
|
||||
/// The foreground executor for running tasks on the main thread.
|
||||
pub foreground_executor: ForegroundExecutor,
|
||||
dispatcher: TestDispatcher,
|
||||
text_system: Arc<TextSystem>,
|
||||
}
|
||||
|
||||
impl HeadlessAppContext {
|
||||
/// Creates a new headless app context with the given text system.
|
||||
pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
|
||||
Self::with_platform(platform_text_system, Arc::new(()), || None)
|
||||
}
|
||||
|
||||
/// Creates a new headless app context with a custom text system and asset source.
|
||||
pub fn with_asset_source(
|
||||
platform_text_system: Arc<dyn PlatformTextSystem>,
|
||||
asset_source: Arc<dyn AssetSource>,
|
||||
) -> Self {
|
||||
Self::with_platform(platform_text_system, asset_source, || None)
|
||||
}
|
||||
|
||||
/// Creates a new headless app context with the given text system, asset source,
|
||||
/// and an optional renderer factory for screenshot support.
|
||||
pub fn with_platform(
|
||||
platform_text_system: Arc<dyn PlatformTextSystem>,
|
||||
asset_source: Arc<dyn AssetSource>,
|
||||
renderer_factory: impl Fn() -> Option<Box<dyn PlatformHeadlessRenderer>> + 'static,
|
||||
) -> Self {
|
||||
let seed = std::env::var("SEED")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let dispatcher = TestDispatcher::new(seed);
|
||||
let arc_dispatcher = Arc::new(dispatcher.clone());
|
||||
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
|
||||
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
|
||||
|
||||
let renderer_factory: Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>> =
|
||||
Box::new(renderer_factory);
|
||||
let platform = TestPlatform::with_platform(
|
||||
background_executor.clone(),
|
||||
foreground_executor.clone(),
|
||||
platform_text_system.clone(),
|
||||
Some(renderer_factory),
|
||||
);
|
||||
|
||||
let text_system = Arc::new(TextSystem::new(platform_text_system));
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let app = App::new_app(platform, asset_source, http_client);
|
||||
app.borrow_mut().mode = GpuiMode::test();
|
||||
|
||||
Self {
|
||||
app,
|
||||
background_executor,
|
||||
foreground_executor,
|
||||
dispatcher,
|
||||
text_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a window for headless rendering.
|
||||
pub fn open_window<V: Render + 'static>(
|
||||
&mut self,
|
||||
size: Size<Pixels>,
|
||||
build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
|
||||
) -> Result<WindowHandle<V>> {
|
||||
use crate::{point, px};
|
||||
|
||||
let bounds = Bounds {
|
||||
origin: point(px(0.0), px(0.0)),
|
||||
size,
|
||||
};
|
||||
|
||||
let mut cx = self.app.borrow_mut();
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
focus: false,
|
||||
show: false,
|
||||
..Default::default()
|
||||
},
|
||||
build_root,
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs all pending tasks until parked.
|
||||
pub fn run_until_parked(&self) {
|
||||
self.dispatcher.run_until_parked();
|
||||
}
|
||||
|
||||
/// Advances the simulated clock.
|
||||
pub fn advance_clock(&self, duration: Duration) {
|
||||
self.dispatcher.advance_clock(duration);
|
||||
}
|
||||
|
||||
/// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading).
|
||||
pub fn allow_parking(&self) {
|
||||
self.dispatcher.allow_parking();
|
||||
}
|
||||
|
||||
/// Disables parking mode, returning to deterministic test execution.
|
||||
pub fn forbid_parking(&self) {
|
||||
self.dispatcher.forbid_parking();
|
||||
}
|
||||
|
||||
/// Updates app state.
|
||||
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
|
||||
let mut app = self.app.borrow_mut();
|
||||
f(&mut app)
|
||||
}
|
||||
|
||||
/// Updates a window and calls draw to render.
|
||||
pub fn update_window<R>(
|
||||
&mut self,
|
||||
window: AnyWindowHandle,
|
||||
f: impl FnOnce(AnyView, &mut Window, &mut App) -> R,
|
||||
) -> Result<R> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.update_window(window, f)
|
||||
}
|
||||
|
||||
/// Captures a screenshot from a window.
|
||||
///
|
||||
/// Requires that the context was created with a renderer factory that
|
||||
/// returns `Some` via [`HeadlessAppContext::with_platform`].
|
||||
pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.update_window(window, |_, window, _| window.render_to_image())?
|
||||
}
|
||||
|
||||
/// Returns the text system.
|
||||
pub fn text_system(&self) -> &Arc<TextSystem> {
|
||||
&self.text_system
|
||||
}
|
||||
|
||||
/// Returns the background executor.
|
||||
pub fn background_executor(&self) -> &BackgroundExecutor {
|
||||
&self.background_executor
|
||||
}
|
||||
|
||||
/// Returns the foreground executor.
|
||||
pub fn foreground_executor(&self) -> &ForegroundExecutor {
|
||||
&self.foreground_executor
|
||||
}
|
||||
}
|
||||
|
||||
impl AppContext for HeadlessAppContext {
|
||||
fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.new(build_entity)
|
||||
}
|
||||
|
||||
fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.reserve_entity()
|
||||
}
|
||||
|
||||
fn insert_entity<T: 'static>(
|
||||
&mut self,
|
||||
reservation: Reservation<T>,
|
||||
build_entity: impl FnOnce(&mut Context<T>) -> T,
|
||||
) -> Entity<T> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.insert_entity(reservation, build_entity)
|
||||
}
|
||||
|
||||
fn update_entity<T: 'static, R>(
|
||||
&mut self,
|
||||
handle: &Entity<T>,
|
||||
update: impl FnOnce(&mut T, &mut Context<T>) -> R,
|
||||
) -> R {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.update_entity(handle, update)
|
||||
}
|
||||
|
||||
fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> GpuiBorrow<'a, T>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.")
|
||||
}
|
||||
|
||||
fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
app.read_entity(handle, read)
|
||||
}
|
||||
|
||||
fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(AnyView, &mut Window, &mut App) -> T,
|
||||
{
|
||||
let mut lock = self.app.borrow_mut();
|
||||
lock.update_window(window, f)
|
||||
}
|
||||
|
||||
fn read_window<T, R>(
|
||||
&self,
|
||||
window: &WindowHandle<T>,
|
||||
read: impl FnOnce(Entity<T>, &App) -> R,
|
||||
) -> Result<R>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
app.read_window(window, read)
|
||||
}
|
||||
|
||||
fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
|
||||
where
|
||||
R: Send + 'static,
|
||||
{
|
||||
self.background_executor.spawn(future)
|
||||
}
|
||||
|
||||
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
|
||||
where
|
||||
G: Global,
|
||||
{
|
||||
let app = self.app.borrow();
|
||||
app.read_global(callback)
|
||||
}
|
||||
}
|
||||
607
crates/gpui/src/app/test_app.rs
Normal file
607
crates/gpui/src/app/test_app.rs
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
//! A clean testing API for GPUI applications.
|
||||
//!
|
||||
//! `TestApp` provides a simpler alternative to `TestAppContext` with:
|
||||
//! - Automatic effect flushing after updates
|
||||
//! - Clean window creation and inspection
|
||||
//! - Input simulation helpers
|
||||
//!
|
||||
//! # Example
|
||||
//! ```ignore
|
||||
//! #[test]
|
||||
//! fn test_my_view() {
|
||||
//! let mut app = TestApp::new();
|
||||
//!
|
||||
//! let mut window = app.open_window(|window, cx| {
|
||||
//! MyView::new(window, cx)
|
||||
//! });
|
||||
//!
|
||||
//! window.update(|view, window, cx| {
|
||||
//! view.do_something(cx);
|
||||
//! });
|
||||
//!
|
||||
//! // Check rendered state
|
||||
//! assert_eq!(window.title(), Some("Expected Title"));
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::{
|
||||
AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext,
|
||||
Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke,
|
||||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform,
|
||||
PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem,
|
||||
Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode,
|
||||
};
|
||||
use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
/// A test application context with a clean API.
|
||||
///
|
||||
/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after
|
||||
/// each update and provides simpler window management.
|
||||
pub struct TestApp {
|
||||
app: Rc<AppCell>,
|
||||
platform: Rc<TestPlatform>,
|
||||
background_executor: BackgroundExecutor,
|
||||
foreground_executor: ForegroundExecutor,
|
||||
#[allow(dead_code)]
|
||||
dispatcher: TestDispatcher,
|
||||
text_system: Arc<TextSystem>,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
/// Create a new test application.
|
||||
pub fn new() -> Self {
|
||||
Self::with_seed(0)
|
||||
}
|
||||
|
||||
/// Create a new test application with a specific random seed.
|
||||
pub fn with_seed(seed: u64) -> Self {
|
||||
Self::build(seed, None, Arc::new(()))
|
||||
}
|
||||
|
||||
/// Create a new test application with a custom text system for real font shaping.
|
||||
pub fn with_text_system(text_system: Arc<dyn PlatformTextSystem>) -> Self {
|
||||
Self::build(0, Some(text_system), Arc::new(()))
|
||||
}
|
||||
|
||||
/// Create a new test application with a custom text system and asset source.
|
||||
pub fn with_text_system_and_assets(
|
||||
text_system: Arc<dyn PlatformTextSystem>,
|
||||
asset_source: Arc<dyn crate::AssetSource>,
|
||||
) -> Self {
|
||||
Self::build(0, Some(text_system), asset_source)
|
||||
}
|
||||
|
||||
fn build(
|
||||
seed: u64,
|
||||
platform_text_system: Option<Arc<dyn PlatformTextSystem>>,
|
||||
asset_source: Arc<dyn crate::AssetSource>,
|
||||
) -> Self {
|
||||
let dispatcher = TestDispatcher::new(seed);
|
||||
let arc_dispatcher = Arc::new(dispatcher.clone());
|
||||
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
|
||||
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
|
||||
let platform = match platform_text_system.clone() {
|
||||
Some(ts) => TestPlatform::with_text_system(
|
||||
background_executor.clone(),
|
||||
foreground_executor.clone(),
|
||||
ts,
|
||||
),
|
||||
None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()),
|
||||
};
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let text_system = Arc::new(TextSystem::new(
|
||||
platform_text_system.unwrap_or_else(|| platform.text_system.clone()),
|
||||
));
|
||||
|
||||
let app = App::new_app(platform.clone(), asset_source, http_client);
|
||||
app.borrow_mut().mode = GpuiMode::test();
|
||||
|
||||
Self {
|
||||
app,
|
||||
platform,
|
||||
background_executor,
|
||||
foreground_executor,
|
||||
dispatcher,
|
||||
text_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a closure with mutable access to the App context.
|
||||
/// Automatically runs until parked after the closure completes.
|
||||
pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
|
||||
let result = {
|
||||
let mut app = self.app.borrow_mut();
|
||||
app.update(f)
|
||||
};
|
||||
self.run_until_parked();
|
||||
result
|
||||
}
|
||||
|
||||
/// Run a closure with read-only access to the App context.
|
||||
pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
|
||||
let app = self.app.borrow();
|
||||
f(&app)
|
||||
}
|
||||
|
||||
/// Create a new entity in the app.
|
||||
pub fn new_entity<T: 'static>(
|
||||
&mut self,
|
||||
build: impl FnOnce(&mut Context<T>) -> T,
|
||||
) -> Entity<T> {
|
||||
self.update(|cx| cx.new(build))
|
||||
}
|
||||
|
||||
/// Update an entity.
|
||||
pub fn update_entity<T: 'static, R>(
|
||||
&mut self,
|
||||
entity: &Entity<T>,
|
||||
f: impl FnOnce(&mut T, &mut Context<T>) -> R,
|
||||
) -> R {
|
||||
self.update(|cx| entity.update(cx, f))
|
||||
}
|
||||
|
||||
/// Read an entity.
|
||||
pub fn read_entity<T: 'static, R>(
|
||||
&self,
|
||||
entity: &Entity<T>,
|
||||
f: impl FnOnce(&T, &App) -> R,
|
||||
) -> R {
|
||||
self.read(|cx| f(entity.read(cx), cx))
|
||||
}
|
||||
|
||||
/// Open a test window with the given root view, using maximized bounds.
|
||||
pub fn open_window<V: Render + 'static>(
|
||||
&mut self,
|
||||
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
|
||||
) -> TestAppWindow<V> {
|
||||
let bounds = self.read(|cx| Bounds::maximized(None, cx));
|
||||
let handle = self.update(|cx| {
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| build_view(window, cx)),
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
TestAppWindow {
|
||||
handle,
|
||||
app: self.app.clone(),
|
||||
platform: self.platform.clone(),
|
||||
background_executor: self.background_executor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a test window with specific options.
|
||||
pub fn open_window_with_options<V: Render + 'static>(
|
||||
&mut self,
|
||||
options: WindowOptions,
|
||||
build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
|
||||
) -> TestAppWindow<V> {
|
||||
let handle = self.update(|cx| {
|
||||
cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx)))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
TestAppWindow {
|
||||
handle,
|
||||
app: self.app.clone(),
|
||||
platform: self.platform.clone(),
|
||||
background_executor: self.background_executor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run pending tasks until there's nothing left to do.
|
||||
pub fn run_until_parked(&self) {
|
||||
self.background_executor.run_until_parked();
|
||||
}
|
||||
|
||||
/// Advance the simulated clock by the given duration.
|
||||
pub fn advance_clock(&self, duration: Duration) {
|
||||
self.background_executor.advance_clock(duration);
|
||||
}
|
||||
|
||||
/// Spawn a future on the foreground executor.
|
||||
pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task<R>
|
||||
where
|
||||
Fut: Future<Output = R> + 'static,
|
||||
R: 'static,
|
||||
{
|
||||
self.foreground_executor.spawn(f(self.to_async()))
|
||||
}
|
||||
|
||||
/// Spawn a future on the background executor.
|
||||
pub fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
|
||||
where
|
||||
R: Send + 'static,
|
||||
{
|
||||
self.background_executor.spawn(future)
|
||||
}
|
||||
|
||||
/// Get an async handle to the app.
|
||||
pub fn to_async(&self) -> AsyncApp {
|
||||
AsyncApp {
|
||||
app: Rc::downgrade(&self.app),
|
||||
background_executor: self.background_executor.clone(),
|
||||
foreground_executor: self.foreground_executor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the background executor.
|
||||
pub fn background_executor(&self) -> &BackgroundExecutor {
|
||||
&self.background_executor
|
||||
}
|
||||
|
||||
/// Get the foreground executor.
|
||||
pub fn foreground_executor(&self) -> &ForegroundExecutor {
|
||||
&self.foreground_executor
|
||||
}
|
||||
|
||||
/// Get the text system.
|
||||
pub fn text_system(&self) -> &Arc<TextSystem> {
|
||||
&self.text_system
|
||||
}
|
||||
|
||||
/// Check if a global of the given type exists.
|
||||
pub fn has_global<G: Global>(&self) -> bool {
|
||||
self.read(|cx| cx.has_global::<G>())
|
||||
}
|
||||
|
||||
/// Set a global value.
|
||||
pub fn set_global<G: Global>(&mut self, global: G) {
|
||||
self.update(|cx| cx.set_global(global));
|
||||
}
|
||||
|
||||
/// Read a global value.
|
||||
pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
|
||||
self.read(|cx| f(cx.global(), cx))
|
||||
}
|
||||
|
||||
/// Update a global value.
|
||||
pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
|
||||
self.update(|cx| cx.update_global(f))
|
||||
}
|
||||
|
||||
// Platform simulation methods
|
||||
|
||||
/// Write text to the simulated clipboard.
|
||||
pub fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
self.platform.write_to_clipboard(item);
|
||||
}
|
||||
|
||||
/// Read from the simulated clipboard.
|
||||
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.platform.read_from_clipboard()
|
||||
}
|
||||
|
||||
/// Get URLs that have been opened via `cx.open_url()`.
|
||||
pub fn opened_url(&self) -> Option<String> {
|
||||
self.platform.opened_url.borrow().clone()
|
||||
}
|
||||
|
||||
/// Check if a file path prompt is pending.
|
||||
pub fn did_prompt_for_new_path(&self) -> bool {
|
||||
self.platform.did_prompt_for_new_path()
|
||||
}
|
||||
|
||||
/// Simulate answering a path selection dialog.
|
||||
pub fn simulate_new_path_selection(
|
||||
&self,
|
||||
select: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>,
|
||||
) {
|
||||
self.platform.simulate_new_path_selection(select);
|
||||
}
|
||||
|
||||
/// Check if a prompt dialog is pending.
|
||||
pub fn has_pending_prompt(&self) -> bool {
|
||||
self.platform.has_pending_prompt()
|
||||
}
|
||||
|
||||
/// Simulate answering a prompt dialog.
|
||||
pub fn simulate_prompt_answer(&self, button: &str) {
|
||||
self.platform.simulate_prompt_answer(button);
|
||||
}
|
||||
|
||||
/// Get all open windows.
|
||||
pub fn windows(&self) -> Vec<AnyWindowHandle> {
|
||||
self.read(|cx| cx.windows())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestApp {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A test window with inspection and simulation capabilities.
|
||||
pub struct TestAppWindow<V> {
|
||||
handle: WindowHandle<V>,
|
||||
app: Rc<AppCell>,
|
||||
platform: Rc<TestPlatform>,
|
||||
background_executor: BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl<V: 'static + Render> TestAppWindow<V> {
|
||||
/// Get the window handle.
|
||||
pub fn handle(&self) -> WindowHandle<V> {
|
||||
self.handle
|
||||
}
|
||||
|
||||
/// Get the root view entity.
|
||||
pub fn root(&self) -> Entity<V> {
|
||||
let mut app = self.app.borrow_mut();
|
||||
let any_handle: AnyWindowHandle = self.handle.into();
|
||||
app.update_window(any_handle, |root_view, _, _| {
|
||||
root_view.downcast::<V>().expect("root view type mismatch")
|
||||
})
|
||||
.expect("window not found")
|
||||
}
|
||||
|
||||
/// Update the root view.
|
||||
pub fn update<R>(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context<V>) -> R) -> R {
|
||||
let result = {
|
||||
let mut app = self.app.borrow_mut();
|
||||
let any_handle: AnyWindowHandle = self.handle.into();
|
||||
app.update_window(any_handle, |root_view, window, cx| {
|
||||
let view = root_view.downcast::<V>().expect("root view type mismatch");
|
||||
view.update(cx, |view, cx| f(view, window, cx))
|
||||
})
|
||||
.expect("window not found")
|
||||
};
|
||||
self.background_executor.run_until_parked();
|
||||
result
|
||||
}
|
||||
|
||||
/// Read the root view.
|
||||
pub fn read<R>(&self, f: impl FnOnce(&V, &App) -> R) -> R {
|
||||
let app = self.app.borrow();
|
||||
let view = self
|
||||
.app
|
||||
.borrow()
|
||||
.windows
|
||||
.get(self.handle.window_id())
|
||||
.and_then(|w| w.as_ref())
|
||||
.and_then(|w| w.root.clone())
|
||||
.and_then(|r| r.downcast::<V>().ok())
|
||||
.expect("window or root view not found");
|
||||
f(view.read(&app), &app)
|
||||
}
|
||||
|
||||
/// Get the window title.
|
||||
pub fn title(&self) -> Option<String> {
|
||||
let app = self.app.borrow();
|
||||
app.read_window(&self.handle, |_, _cx| {
|
||||
// TODO: expose title through Window API
|
||||
None
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Simulate a keystroke.
|
||||
pub fn simulate_keystroke(&mut self, keystroke: &str) {
|
||||
let keystroke = Keystroke::parse(keystroke).unwrap();
|
||||
{
|
||||
let mut app = self.app.borrow_mut();
|
||||
let any_handle: AnyWindowHandle = self.handle.into();
|
||||
app.update_window(any_handle, |_, window, cx| {
|
||||
window.dispatch_keystroke(keystroke, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
self.background_executor.run_until_parked();
|
||||
}
|
||||
|
||||
/// Simulate multiple keystrokes (space-separated).
|
||||
pub fn simulate_keystrokes(&mut self, keystrokes: &str) {
|
||||
for keystroke in keystrokes.split(' ') {
|
||||
self.simulate_keystroke(keystroke);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate typing text.
|
||||
pub fn simulate_input(&mut self, input: &str) {
|
||||
for char in input.chars() {
|
||||
self.simulate_keystroke(&char.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a mouse move.
|
||||
pub fn simulate_mouse_move(&mut self, position: Point<Pixels>) {
|
||||
self.simulate_event(MouseMoveEvent {
|
||||
position,
|
||||
modifiers: Default::default(),
|
||||
pressed_button: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulate a mouse down event.
|
||||
pub fn simulate_mouse_down(&mut self, position: Point<Pixels>, button: MouseButton) {
|
||||
self.simulate_event(MouseDownEvent {
|
||||
position,
|
||||
button,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulate a mouse up event.
|
||||
pub fn simulate_mouse_up(&mut self, position: Point<Pixels>, button: MouseButton) {
|
||||
self.simulate_event(MouseUpEvent {
|
||||
position,
|
||||
button,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulate a click at the given position.
|
||||
pub fn simulate_click(&mut self, position: Point<Pixels>, button: MouseButton) {
|
||||
self.simulate_mouse_down(position, button);
|
||||
self.simulate_mouse_up(position, button);
|
||||
}
|
||||
|
||||
/// Simulate a scroll event.
|
||||
pub fn simulate_scroll(&mut self, position: Point<Pixels>, delta: Point<Pixels>) {
|
||||
self.simulate_event(crate::ScrollWheelEvent {
|
||||
position,
|
||||
delta: crate::ScrollDelta::Pixels(delta),
|
||||
modifiers: Default::default(),
|
||||
touch_phase: crate::TouchPhase::Moved,
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulate an input event.
|
||||
pub fn simulate_event<E: InputEvent>(&mut self, event: E) {
|
||||
let platform_input = event.to_platform_input();
|
||||
{
|
||||
let mut app = self.app.borrow_mut();
|
||||
let any_handle: AnyWindowHandle = self.handle.into();
|
||||
app.update_window(any_handle, |_, window, cx| {
|
||||
window.dispatch_event(platform_input, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
self.background_executor.run_until_parked();
|
||||
}
|
||||
|
||||
/// Simulate resizing the window.
|
||||
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
|
||||
let window_id = self.handle.window_id();
|
||||
let mut app = self.app.borrow_mut();
|
||||
if let Some(Some(window)) = app.windows.get_mut(window_id) {
|
||||
if let Some(test_window) = window.platform_window.as_test() {
|
||||
test_window.simulate_resize(size);
|
||||
}
|
||||
}
|
||||
drop(app);
|
||||
self.background_executor.run_until_parked();
|
||||
}
|
||||
|
||||
/// Force a redraw of the window.
|
||||
pub fn draw(&mut self) {
|
||||
let mut app = self.app.borrow_mut();
|
||||
let any_handle: AnyWindowHandle = self.handle.into();
|
||||
app.update_window(any_handle, |_, window, cx| {
|
||||
window.draw(cx).clear();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl<V> Clone for TestAppWindow<V> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
handle: self.handle,
|
||||
app: self.app.clone(),
|
||||
platform: self.platform.clone(),
|
||||
background_executor: self.background_executor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{FocusHandle, Focusable, div, prelude::*};
|
||||
|
||||
struct Counter {
|
||||
count: usize,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Counter {
|
||||
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
Self {
|
||||
count: 0,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(&mut self, _cx: &mut Context<Self>) {
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for Counter {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Counter {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(format!("Count: {}", self.count))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_usage() {
|
||||
let mut app = TestApp::new();
|
||||
|
||||
let mut window = app.open_window(Counter::new);
|
||||
|
||||
window.update(|counter, _window, cx| {
|
||||
counter.increment(cx);
|
||||
});
|
||||
|
||||
window.read(|counter, _| {
|
||||
assert_eq!(counter.count, 1);
|
||||
});
|
||||
|
||||
drop(window);
|
||||
app.update(|cx| cx.shutdown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_creation() {
|
||||
let mut app = TestApp::new();
|
||||
|
||||
let entity = app.new_entity(|cx| Counter {
|
||||
count: 42,
|
||||
focus_handle: cx.focus_handle(),
|
||||
});
|
||||
|
||||
app.read_entity(&entity, |counter, _| {
|
||||
assert_eq!(counter.count, 42);
|
||||
});
|
||||
|
||||
app.update_entity(&entity, |counter, _cx| {
|
||||
counter.count += 1;
|
||||
});
|
||||
|
||||
app.read_entity(&entity, |counter, _| {
|
||||
assert_eq!(counter.count, 43);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_globals() {
|
||||
let mut app = TestApp::new();
|
||||
|
||||
struct MyGlobal(String);
|
||||
impl Global for MyGlobal {}
|
||||
|
||||
assert!(!app.has_global::<MyGlobal>());
|
||||
|
||||
app.set_global(MyGlobal("hello".into()));
|
||||
|
||||
assert!(app.has_global::<MyGlobal>());
|
||||
|
||||
app.read_global::<MyGlobal, _>(|global, _| {
|
||||
assert_eq!(global.0, "hello");
|
||||
});
|
||||
|
||||
app.update_global::<MyGlobal, _>(|global, _| {
|
||||
global.0 = "world".into();
|
||||
});
|
||||
|
||||
app.read_global::<MyGlobal, _>(|global, _| {
|
||||
assert_eq!(global.0, "world");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
Loading…
Reference in a new issue