Add TestApp and TestAppWindow for cleaner GPUI testing

Nathan Sobo created

TestApp provides a simpler alternative to TestAppContext with:
- Automatic effect flushing after updates
- Clean window creation returning typed TestAppWindow<V>
- 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

Change summary

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

Detailed changes

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<V: 'static + Render> TestAppWindow<V> {
     }
 
     /// Update the root view.
+    /// Automatically draws the window after the update to ensure the scene is current.
     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();
@@ -325,6 +326,7 @@ impl<V: 'static + Render> TestAppWindow<V> {
             .expect("window not found")
         };
         self.background_executor.run_until_parked();
+        self.draw();
         result
     }
 
@@ -340,7 +342,7 @@ impl<V: 'static + Render> TestAppWindow<V> {
             .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)
+        f(view.read(&app), &app)
     }
 
     /// Get the window title.
@@ -354,6 +356,7 @@ impl<V: 'static + Render> TestAppWindow<V> {
     }
 
     /// 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<V: 'static + Render> TestAppWindow<V> {
             .unwrap();
         }
         self.background_executor.run_until_parked();
+        self.draw();
     }
 
     /// Simulate multiple keystrokes (space-separated).
@@ -428,6 +432,7 @@ impl<V: 'static + Render> TestAppWindow<V> {
     }
 
     /// Simulate an input event.
+    /// Automatically draws the window after the event.
     pub fn simulate_event<E: InputEvent>(&mut self, event: E) {
         let platform_input = event.to_platform_input();
         {
@@ -439,9 +444,11 @@ impl<V: 'static + Render> TestAppWindow<V> {
             .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<Pixels>) {
         let window_id = self.handle.window_id();
         let mut app = self.app.borrow_mut();
@@ -452,6 +459,7 @@ impl<V: 'static + Render> TestAppWindow<V> {
         }
         drop(app);
         self.background_executor.run_until_parked();
+        self.draw();
     }
 
     /// Force a redraw of the window.
@@ -463,6 +471,18 @@ impl<V: 'static + Render> TestAppWindow<V> {
         })
         .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<V> Clone for TestAppWindow<V> {

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<Hsla> {
+        if self.tag == BackgroundTag::Solid {
+            Some(self.solid)
+        } else {
+            None
+        }
+    }
+
     /// Use specified color space for color interpolation.
     ///
     /// <https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method>

crates/gpui/src/scene.rs 🔗

@@ -20,6 +20,110 @@ pub(crate) type PathVertex_ScaledPixels = PathVertex<ScaledPixels>;
 
 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<ScaledPixels>,
+        /// Background color (if solid).
+        pub background_color: Option<Hsla>,
+        /// Border color.
+        pub border_color: Hsla,
+    }
+
+    /// A rendered text glyph.
+    #[derive(Debug, Clone)]
+    pub struct RenderedGlyph {
+        /// Origin position in scaled pixels.
+        pub origin: Point<ScaledPixels>,
+        /// Size in scaled pixels.
+        pub size: crate::Size<ScaledPixels>,
+        /// 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<RenderedQuad>,
+        /// All rendered text glyphs.
+        pub glyphs: Vec<RenderedGlyph>,
+        /// 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<f32> {
+            let mut positions: Vec<f32> = 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<f32> {
+            let mut positions: Vec<f32> = 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<PaintOperation>,
@@ -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<ScaledPixels>,
     pub content_mask: ContentMask<ScaledPixels>,
     pub color: Hsla,
@@ -638,7 +772,7 @@ impl From<MonochromeSprite> 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<ScaledPixels>,