From 96ddbd4e13c7b345572fd5f6be875e2d025afad5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 14 Dec 2025 10:43:35 -0700 Subject: [PATCH] Add TestApp and TestAppWindow for cleaner GPUI testing TestApp provides a simpler alternative to TestAppContext with: - Automatic effect flushing after updates - Clean window creation returning typed TestAppWindow - Scene inspection via SceneSnapshot - Input simulation helpers Also adds: - Background::as_solid() helper in color.rs - SceneSnapshot for inspecting rendered quads/glyphs in scene.rs --- crates/gpui/src/app/test_app.rs | 26 +++++- crates/gpui/src/color.rs | 9 +++ crates/gpui/src/scene.rs | 138 +++++++++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs index e97934803ac97b025c9ba22b8c3821c1405643f2..8299da132e9e951be9074fe7a448ee29231062b6 100644 --- a/crates/gpui/src/app/test_app.rs +++ b/crates/gpui/src/app/test_app.rs @@ -28,8 +28,8 @@ use crate::{ AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, - Size, Task, TestDispatcher, TestPlatform, TextSystem, Window, WindowBounds, WindowHandle, - WindowOptions, + SceneSnapshot, Size, Task, TestDispatcher, TestPlatform, TextSystem, Window, WindowBounds, + WindowHandle, WindowOptions, app::GpuiMode, }; use rand::{SeedableRng, rngs::StdRng}; @@ -312,6 +312,7 @@ impl TestAppWindow { } /// Update the root view. + /// Automatically draws the window after the update to ensure the scene is current. pub fn update(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context) -> R) -> R { let result = { let mut app = self.app.borrow_mut(); @@ -325,6 +326,7 @@ impl TestAppWindow { .expect("window not found") }; self.background_executor.run_until_parked(); + self.draw(); result } @@ -340,7 +342,7 @@ impl TestAppWindow { .and_then(|w| w.root.clone()) .and_then(|r| r.downcast::().ok()) .expect("window or root view not found"); - f(view.read(&*app), &*app) + f(view.read(&app), &app) } /// Get the window title. @@ -354,6 +356,7 @@ impl TestAppWindow { } /// Simulate a keystroke. + /// Automatically draws the window after the keystroke. pub fn simulate_keystroke(&mut self, keystroke: &str) { let keystroke = Keystroke::parse(keystroke).unwrap(); { @@ -365,6 +368,7 @@ impl TestAppWindow { .unwrap(); } self.background_executor.run_until_parked(); + self.draw(); } /// Simulate multiple keystrokes (space-separated). @@ -428,6 +432,7 @@ impl TestAppWindow { } /// Simulate an input event. + /// Automatically draws the window after the event. pub fn simulate_event(&mut self, event: E) { let platform_input = event.to_platform_input(); { @@ -439,9 +444,11 @@ impl TestAppWindow { .unwrap(); } self.background_executor.run_until_parked(); + self.draw(); } /// Simulate resizing the window. + /// Automatically draws the window after the resize. pub fn simulate_resize(&mut self, size: Size) { let window_id = self.handle.window_id(); let mut app = self.app.borrow_mut(); @@ -452,6 +459,7 @@ impl TestAppWindow { } drop(app); self.background_executor.run_until_parked(); + self.draw(); } /// Force a redraw of the window. @@ -463,6 +471,18 @@ impl TestAppWindow { }) .unwrap(); } + + /// Get a snapshot of the rendered scene for inspection. + /// The scene is automatically kept up to date after `update()` and `simulate_*()` calls. + pub fn scene_snapshot(&self) -> SceneSnapshot { + let app = self.app.borrow(); + let window = app + .windows + .get(self.handle.window_id()) + .and_then(|w| w.as_ref()) + .expect("window not found"); + window.rendered_frame.scene.snapshot() + } } impl Clone for TestAppWindow { diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 3af5731bb57dad81a522d93a77c0aec871ae47cb..1786d474056cd03ebb0c52ba4a3617e153bea889 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -808,6 +808,15 @@ impl LinearColorStop { } impl Background { + /// Returns the solid color if this is a solid background, None otherwise. + pub fn as_solid(&self) -> Option { + if self.tag == BackgroundTag::Solid { + Some(self.solid) + } else { + None + } + } + /// Use specified color space for color interpolation. /// /// diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 758d06e5972e36b15fe0b8ce9d7d3193f8d3844d..04db1794fe896b1f85c419815cb2a60bd21c9421 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -20,6 +20,110 @@ pub(crate) type PathVertex_ScaledPixels = PathVertex; pub(crate) type DrawOrder = u32; +/// Test-only scene snapshot for inspecting rendered content. +#[cfg(any(test, feature = "test-support"))] +pub mod test_scene { + use crate::{Bounds, Hsla, Point, ScaledPixels}; + + /// A rendered quad (background, border, cursor, selection, etc.) + #[derive(Debug, Clone)] + pub struct RenderedQuad { + /// Bounds in scaled pixels. + pub bounds: Bounds, + /// Background color (if solid). + pub background_color: Option, + /// Border color. + pub border_color: Hsla, + } + + /// A rendered text glyph. + #[derive(Debug, Clone)] + pub struct RenderedGlyph { + /// Origin position in scaled pixels. + pub origin: Point, + /// Size in scaled pixels. + pub size: crate::Size, + /// Color of the glyph. + pub color: Hsla, + } + + /// Snapshot of scene contents for testing. + #[derive(Debug, Default)] + pub struct SceneSnapshot { + /// All rendered quads. + pub quads: Vec, + /// All rendered text glyphs. + pub glyphs: Vec, + /// Number of shadow primitives. + pub shadow_count: usize, + /// Number of path primitives. + pub path_count: usize, + /// Number of underline primitives. + pub underline_count: usize, + /// Number of polychrome sprites (images, emoji). + pub polychrome_sprite_count: usize, + /// Number of surface primitives. + pub surface_count: usize, + } + + impl SceneSnapshot { + /// Get unique Y positions of quads, sorted. + pub fn quad_y_positions(&self) -> Vec { + let mut positions: Vec = self.quads.iter().map(|q| q.bounds.origin.y.0).collect(); + positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + positions.dedup(); + positions + } + + /// Get unique Y positions of glyphs, sorted. + pub fn glyph_y_positions(&self) -> Vec { + let mut positions: Vec = self.glyphs.iter().map(|g| g.origin.y.0).collect(); + positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + positions.dedup(); + positions + } + + /// Find quads within a Y range. + pub fn quads_in_y_range(&self, min_y: f32, max_y: f32) -> Vec<&RenderedQuad> { + self.quads + .iter() + .filter(|q| { + let y = q.bounds.origin.y.0; + y >= min_y && y < max_y + }) + .collect() + } + + /// Find glyphs within a Y range. + pub fn glyphs_in_y_range(&self, min_y: f32, max_y: f32) -> Vec<&RenderedGlyph> { + self.glyphs + .iter() + .filter(|g| { + let y = g.origin.y.0; + y >= min_y && y < max_y + }) + .collect() + } + + /// Debug summary string. + pub fn summary(&self) -> String { + format!( + "quads: {}, glyphs: {}, shadows: {}, paths: {}, underlines: {}, polychrome: {}, surfaces: {}", + self.quads.len(), + self.glyphs.len(), + self.shadow_count, + self.path_count, + self.underline_count, + self.polychrome_sprite_count, + self.surface_count, + ) + } + } +} + +#[cfg(any(test, feature = "test-support"))] +pub use test_scene::*; + #[derive(Default)] pub(crate) struct Scene { pub(crate) paint_operations: Vec, @@ -124,6 +228,36 @@ impl Scene { } } + /// Create a snapshot of the scene for testing. + #[cfg(any(test, feature = "test-support"))] + pub fn snapshot(&self) -> SceneSnapshot { + let quads = self.quads.iter().map(|q| { + RenderedQuad { + bounds: q.bounds, + background_color: q.background.as_solid(), + border_color: q.border_color, + } + }).collect(); + + let glyphs = self.monochrome_sprites.iter().map(|s| { + RenderedGlyph { + origin: s.bounds.origin, + size: s.bounds.size, + color: s.color, + } + }).collect(); + + SceneSnapshot { + quads, + glyphs, + shadow_count: self.shadows.len(), + path_count: self.paths.len(), + underline_count: self.underlines.len(), + polychrome_sprite_count: self.polychrome_sprites.len(), + surface_count: self.surfaces.len(), + } + } + pub fn finish(&mut self) { self.shadows.sort_by_key(|shadow| shadow.order); self.quads.sort_by_key(|quad| quad.order); @@ -620,7 +754,7 @@ impl Default for TransformationMatrix { #[repr(C)] pub(crate) struct MonochromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub bounds: Bounds, pub content_mask: ContentMask, pub color: Hsla, @@ -638,7 +772,7 @@ impl From for Primitive { #[repr(C)] pub(crate) struct PolychromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub grayscale: bool, pub opacity: f32, pub bounds: Bounds,