From 5267c44adfe266964ea40f1749d41b27d39b0988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 3 Oct 2026 23:22:49 -0400 Subject: [PATCH] =?UTF-8?q?Rendering=20=C2=B7=20RenderSurface:=20host=20fr?= =?UTF-8?q?ames=20at=20the=20box's=20device=20pixels,=20redrawn=20on=20a?= =?UTF-8?q?=20new=20size=20or=20revision,=20freed=20from=20the=20atlas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASKS.md | 2 +- src/lib.rs | 1 + src/rendering/mod.rs | 5 ++ src/rendering/surface.rs | 138 +++++++++++++++++++++++++++++++++++++++ src/rendering/tests.rs | 129 ++++++++++++++++++++++++++++++++++++ tasks/reviews.md | 1 + 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/rendering/mod.rs create mode 100644 src/rendering/surface.rs create mode 100644 src/rendering/tests.rs diff --git a/TASKS.md b/TASKS.md index 3e980ce..bf4ae5a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -179,7 +179,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T48m Docs · E2E: the Docs routes, rail, list, previous and next, and code blocks under Playwright - [ ] T49 Acceptance: live URL and MVP checklist - [x] T50a Rendering · Plan: chapter 44 in the PRD, `tasks/ch44.md`, these lines -- [ ] T50b Rendering · Surface: `rendering::RenderSurface`, tested — `tasks/ch44.md` +- [x] T50b Rendering · Surface: `rendering::RenderSurface`, tested — `tasks/ch44.md` - [ ] T50c Rendering · Gallery: a Rendering page, its story, `stories.json`; the gallery runs on Linux - [ ] T50d Rendering · Web: the story runs live on the site from the wasm gallery, checked in a browser - [ ] T50e Rendering · Platforms: CI builds and tests the crate on Linux, macOS and Windows, arm64 and x86_64 diff --git a/src/lib.rs b/src/lib.rs index 0732d4d..0046751 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod onboarding; pub mod overlays; pub mod primitives; pub mod project; +pub mod rendering; pub mod settings; pub mod shell; pub mod tables; diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs new file mode 100644 index 0000000..48ebcda --- /dev/null +++ b/src/rendering/mod.rs @@ -0,0 +1,5 @@ +mod surface; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use surface::RenderSurface; diff --git a/src/rendering/surface.rs b/src/rendering/surface.rs new file mode 100644 index 0000000..2d1e3f3 --- /dev/null +++ b/src/rendering/surface.rs @@ -0,0 +1,138 @@ +use std::{rc::Rc, sync::Arc}; + +use gpui::{ + App, Bounds, Corners, DevicePixels, ElementId, IntoElement, ParentElement, Pixels, RenderImage, + RenderOnce, Size, Styled, Window, canvas, prelude::*, +}; +use web_time::Instant; + +use crate::primitives::{checked_ratio, framed}; + +type Draw = Rc, &mut App) -> RenderImage>; + +/// The frame on screen and what it was drawn for. +struct Drawn { + size: Size, + revision: u64, + frame: Arc, +} + +/// Pixels the host draws, one frame at the box's size in device pixels, in the shape `ratio` gives it. It draws again when that size or the host's revision changes, and frees the old frame from the atlas. A frame is BGRA, as gpui's `RenderImage` holds it. +#[derive(IntoElement)] +pub struct RenderSurface { + id: ElementId, + ratio: f32, + revision: u64, + draw: Draw, +} + +impl RenderSurface { + /// `ratio` is the box's width over its height; `draw` makes a frame of exactly the size it is given. + pub fn new( + id: impl Into, + ratio: f32, + draw: impl Fn(Size, &mut App) -> RenderImage + 'static, + ) -> Self { + Self { + id: id.into(), + ratio: checked_ratio(ratio), + revision: 0, + draw: Rc::new(draw), + } + } + + /// Bumped by the host whenever the content changes. + pub fn revision(mut self, revision: u64) -> Self { + self.revision = revision; + self + } +} + +impl RenderOnce for RenderSurface { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // Freed from every atlas on leave. + let shown = window.use_keyed_state((self.id.clone(), "frame"), cx, |_, cx| { + cx.on_release(|shown: &mut Option, cx| { + if let Some(drawn) = shown.take() { + cx.drop_image(drawn.frame, None); + } + }) + .detach(); + None + }); + let (id, revision, draw) = (self.id.clone(), self.revision, self.draw); + framed(self.ratio, cx) + .id(self.id) + .debug_selector(|| "render-surface".into()) + .child( + canvas( + move |bounds, window, cx| { + let size = bounds.size.to_device_pixels(window.scale_factor()); + if size.width.0 == 0 || size.height.0 == 0 { + return None; + } + let stale = shown + .read(cx) + .as_ref() + .is_none_or(|drawn| drawn.size != size || drawn.revision != revision); + if stale { + let frame = drawn(&id, size, revision, &draw, cx); + let old = shown.update(cx, |shown, _| { + shown.replace(Drawn { + size, + revision, + frame, + }) + }); + if let Some(old) = old { + window + .drop_image(old.frame) + .expect("the atlas frees a frame"); + } + } + shown.read(cx).as_ref().map(|drawn| drawn.frame.clone()) + }, + |bounds: Bounds, frame, window, _| { + if let Some(frame) = frame { + window + .paint_image(bounds, bounds, Corners::default(), frame, 0, false) + .expect("the atlas takes the frame"); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + } +} + +/// The host's frame for `size`, which must match it. +fn drawn( + id: &ElementId, + size: Size, + revision: u64, + draw: &Draw, + cx: &mut App, +) -> Arc { + let start = Instant::now(); + let frame = draw(size, cx); + assert_eq!( + frame.frame_count(), + 1, + "render surface {id}: a frame is one picture" + ); + assert_eq!( + frame.size(0), + size, + "render surface {id}: the host drew the wrong size" + ); + log::debug!( + "render surface {id}: drew {}x{} at revision {revision} in {:?}", + size.width.0, + size.height.0, + start.elapsed() + ); + Arc::new(frame) +} diff --git a/src/rendering/tests.rs b/src/rendering/tests.rs new file mode 100644 index 0000000..9cb739d --- /dev/null +++ b/src/rendering/tests.rs @@ -0,0 +1,129 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, DevicePixels, IntoElement, ParentElement, Render, RenderImage, Size, Styled, + TestAppContext, VisualTestContext, Window, div, px, size, +}; +use image::{Frame, RgbaImage}; + +use super::RenderSurface; +use crate::{layout::tests::narrow_width, theme::Theme}; + +type Asked = Rc>>>; + +/// A surface `width` wide at 2:1, its revision set by the test. +struct Host { + width: f32, + revision: u64, + asked: Asked, + off_by: i32, +} + +impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (asked, off_by) = (self.asked.clone(), self.off_by); + div().w(px(self.width)).child( + RenderSurface::new("surface", 2.0, move |size, _| { + asked.borrow_mut().push(size); + frame(size.width.0 + off_by, size.height.0) + }) + .revision(self.revision), + ) + } +} + +fn frame(width: i32, height: i32) -> RenderImage { + RenderImage::new(vec![Frame::new(RgbaImage::new( + width as u32, + height as u32, + ))]) +} + +fn hosted( + cx: &mut TestAppContext, + width: f32, + off_by: i32, +) -> (gpui::Entity, &mut VisualTestContext, Asked) { + cx.update(Theme::init); + let asked = Asked::default(); + let seen = asked.clone(); + let (host, cx) = cx.add_window_view(move |_, _| Host { + width, + revision: 0, + asked: seen, + off_by, + }); + redraw(cx); + (host, cx, asked) +} + +fn redraw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +fn px_size(width: i32, height: i32) -> Size { + size(DevicePixels(width), DevicePixels(height)) +} + +#[gpui::test] +fn draws_once_at_the_box_size_in_device_pixels(cx: &mut TestAppContext) { + let (_, cx, asked) = hosted(cx, 200.0, 0); + assert_eq!(cx.update(|window, _| window.scale_factor()), 2.0); + redraw(cx); + redraw(cx); + assert_eq!( + *asked.borrow(), + [px_size(400, 200)], + "one frame for one size" + ); + cx.update(|window, _| window.set_scale_factor(1.0)); + redraw(cx); + assert_eq!( + asked.borrow().last(), + Some(&px_size(200, 100)), + "a coarser screen asks for fewer pixels" + ); +} + +#[gpui::test] +fn a_new_revision_or_width_draws_again(cx: &mut TestAppContext) { + let (host, cx, asked) = hosted(cx, 200.0, 0); + host.update(cx, |host, cx| { + host.revision = 1; + cx.notify(); + }); + redraw(cx); + host.update(cx, |host, cx| { + host.width = 120.0; + cx.notify(); + }); + redraw(cx); + assert_eq!( + *asked.borrow(), + [px_size(400, 200), px_size(400, 200), px_size(240, 120)] + ); +} + +#[gpui::test] +fn an_empty_box_asks_for_nothing(cx: &mut TestAppContext) { + let (_, _, asked) = hosted(cx, 0.0, 0); + assert!(asked.borrow().is_empty(), "no frame for no pixels"); +} + +#[gpui::test] +#[should_panic(expected = "the host drew the wrong size")] +fn a_frame_of_the_wrong_size_fails(cx: &mut TestAppContext) { + hosted(cx, 200.0, 1); +} + +#[gpui::test] +fn the_surface_fills_its_column(cx: &mut TestAppContext) { + cx.update(Theme::init); + let width = narrow_width(cx, "render-surface", |_, _| { + RenderSurface::new("surface", 2.0, |size, _| frame(size.width.0, size.height.0)) + .into_any_element() + }); + assert_eq!(width, px(240.0)); +} diff --git a/tasks/reviews.md b/tasks/reviews.md index ffd3f8f..a35d563 100644 --- a/tasks/reviews.md +++ b/tasks/reviews.md @@ -352,3 +352,4 @@ | G3 | 1 | FAIL | RangeSlider thumbs reported the whole track, not the bounds the other thumb leaves | | G3 | 2 | PASS | | | T50a | 1 | PASS | codex gpt-6.1-sol max | +| T50b | 1 | PASS | |