From 35fb8897a0a96f8f27a519e53156def1b95ab0fb 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:31:51 -0400 Subject: [PATCH] =?UTF-8?q?Rendering=20=C2=B7=20gallery=20page:=20a=20band?= =?UTF-8?q?ed=20field=20drawn=20at=20device=20pixels,=20its=20slider=20bum?= =?UTF-8?q?ps=20the=20revision;=20the=20gallery=20builds=20on=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASKS.md | 2 +- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/rendering/mod.rs | 100 ++++++++++++++++++++++++ examples/gallery/stories.json | 3 +- examples/gallery/swipe.rs | 5 ++ scripts/stories.py | 3 + tasks/ch44.md | 10 +-- tasks/reviews.md | 1 + 8 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 examples/gallery/pages/rendering/mod.rs diff --git a/TASKS.md b/TASKS.md index bf4ae5a..ffcac9d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -180,7 +180,7 @@ Per-component lines live in `tasks/`. Tags there: - [ ] T49 Acceptance: live URL and MVP checklist - [x] T50a Rendering · Plan: chapter 44 in the PRD, `tasks/ch44.md`, these lines - [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 +- [x] 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 - [ ] T50f Rendering · Docs: a guide with an example `scripts/check.sh` compiles; AGENTS.md diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index e7ba482..f3d3c62 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -34,6 +34,7 @@ mod onboarding; mod overlays; mod primitives; mod project; +mod rendering; mod settings; mod shell; mod tables; @@ -101,6 +102,7 @@ pub const ALL: &[Page] = &[ maps::PAGE, misc::PAGE, tooling::PAGE, + rendering::PAGE, ]; pub use i18n::install as install_i18n; diff --git a/examples/gallery/pages/rendering/mod.rs b/examples/gallery/pages/rendering/mod.rs new file mode 100644 index 0000000..53e74d0 --- /dev/null +++ b/examples/gallery/pages/rendering/mod.rs @@ -0,0 +1,100 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use ely_gpui_component::{forms::Slider, rendering::RenderSurface, theme::ActiveTheme}; +use gpui::{ + AnyElement, App, DevicePixels, Hsla, IntoElement, ParentElement, RenderImage, Rgba, Size, + Styled, Window, div, px, +}; +use image::{Frame, RgbaImage}; + +use super::Page; +use crate::{ + probe::probe, + step::Step, + ui::{keep, section, set}, +}; + +pub const PAGE: Page = Page { + number: 44, + slug: "rendering", + title: "Rendering", + summary: "Pixels the host draws at the box's size in device pixels, drawn again for a new size or content.", + render, + script: &[ + Step::Click("surface-slider"), + Step::Key("right"), + Step::Key("right"), + Step::Wait(300), + Step::Shot("moved"), + ], +}; + +/// Where the sources sit, as shares of the frame; the first moves. +const SOURCES: [(f32, f32); 3] = [(0.2, 0.5), (0.62, 0.3), (0.78, 0.74)]; +const BANDS: f32 = 8.0; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div().child(surface(window, cx)).into_any_element() +} + +fn surface(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let place = keep("surface-place", || 0.2, window, cx); + let at = *place.read(cx); + let colors = &cx.theme().colors; + let (ground, ink) = (colors.sunken, colors.accent); + section( + "RenderSurface", + "The host draws every pixel of each frame at the box's size in device pixels, here a field of three sources in contour bands. The slider moves a source; the frame draws again only for a new size or revision. Shaders, cameras, video and other GPU work run on the host's side and hand their frames in: gpui shares no device, so zero-copy textures and Metal, OpenGL, Vulkan or Direct3D surfaces have no way in.", + cx, + ) + .child( + div().w(px(720.0)).child( + RenderSurface::new("field", 2.0, move |size, _| field(size, at, ground, ink)) + .revision(revision(at, ground, ink)), + ), + ) + .child(probe( + "surface-slider", + div().w(px(320.0)).child( + Slider::new("surface-place", f64::from(at) * 100.0) + .step(5.0) + .on_change(move |next, _, cx| set(&place, next as f32 / 100.0, cx)), + ), + )) +} + +/// What the frame reads, so a theme fade draws it again. +fn revision(at: f32, ground: Hsla, ink: Hsla) -> u64 { + let mut hasher = DefaultHasher::new(); + for value in [at, ground.h, ground.s, ground.l, ink.h, ink.s, ink.l] { + value.to_bits().hash(&mut hasher); + } + hasher.finish() +} + +/// The field at `size`, one source at `at` across, banded from `ground` to `ink`, in BGRA. +fn field(size: Size, at: f32, ground: Hsla, ink: Hsla) -> RenderImage { + let (width, height) = (size.width.0 as u32, size.height.0 as u32); + let (ground, ink) = (Rgba::from(ground), Rgba::from(ink)); + let reach = height as f32 * 0.22; + let mut sources = SOURCES.map(|(x, y)| (x * width as f32, y * height as f32)); + sources[0].0 = at * width as f32; + let pixels = RgbaImage::from_fn(width, height, |x, y| { + let heat: f32 = sources + .iter() + .map(|(sx, sy)| { + let (dx, dy) = ((x as f32 - sx) / reach, (y as f32 - sy) / reach); + 1.0 / (1.0 + dx * dx + dy * dy) + }) + .sum(); + let band = (heat.min(1.0) * BANDS).floor() / BANDS; + let mix = |from: f32, to: f32| ((from + (to - from) * band) * 255.0).round() as u8; + image::Rgba([ + mix(ground.b, ink.b), + mix(ground.g, ink.g), + mix(ground.r, ink.r), + 255, + ]) + }); + RenderImage::new(vec![Frame::new(pixels)]) +} diff --git a/examples/gallery/stories.json b/examples/gallery/stories.json index b53fde6..49e56dd 100644 --- a/examples/gallery/stories.json +++ b/examples/gallery/stories.json @@ -41,5 +41,6 @@ {"number": 40, "slug": "i18n", "title": "i18n & a11y", "summary": "Messages, numbers and dates in the reader's locale. A right-to-left locale mirrors its stacks.", "stories": [{"title": "I18nProvider · Trans / LocalizedText", "slug": "i18nprovider-trans-localizedtext", "components": ["I18nProvider", "Trans", "LocalizedText"]}, {"title": "LocaleNumber / LocaleDate", "slug": "localenumber-localedate", "components": ["LocaleNumber", "LocaleDate"]}, {"title": "RTL layout", "slug": "rtl-layout", "components": ["RTL layout"]}, {"title": "SkipLink · KeyboardNavigationHelper", "slug": "skiplink-keyboardnavigationhelper", "components": ["SkipLink", "KeyboardNavigationHelper"]}, {"title": "ColorBlindSafe Palette", "slug": "colorblindsafe-palette", "components": ["ColorBlindSafe Palette"]}]}, {"number": 41, "slug": "maps", "title": "Maps", "summary": "Tiles the host supplies, pins with a popup, a picker's crosshair, routes, GeoJSON, heat, clusters and choropleths. The land is Natural Earth's.", "stories": [{"title": "MapView · MapMarker · MapPopup", "slug": "mapview-mapmarker-mappopup", "components": ["MapView", "MapMarker", "MapPopup"]}, {"title": "CoordinateDisplay", "slug": "coordinatedisplay", "components": ["CoordinateDisplay"]}, {"title": "LocationPicker", "slug": "locationpicker", "components": ["LocationPicker"]}, {"title": "RouteLine · GeoJSON Layer", "slug": "routeline-geojson-layer", "components": ["RouteLine", "GeoJSON Layer"]}, {"title": "GeoHeatmap", "slug": "geoheatmap", "components": ["GeoHeatmap"]}, {"title": "MapCluster", "slug": "mapcluster", "components": ["MapCluster"]}, {"title": "ChoroplethMap · WorldMap", "slug": "choroplethmap-worldmap", "components": ["ChoroplethMap", "WorldMap"]}]}, {"number": 42, "slug": "misc", "title": "Misc", "summary": "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha, a poll, a survey, a quiz, flashcards, licenses, terms, a cookie banner, data out and in, and web views.", "stories": [{"title": "Clock / WorldClock", "slug": "clock-worldclock", "components": ["Clock", "WorldClock"]}, {"title": "Stopwatch", "slug": "stopwatch", "components": ["Stopwatch"]}, {"title": "Calculator", "slug": "calculator", "components": ["Calculator"]}, {"title": "UnitConverter", "slug": "unitconverter", "components": ["UnitConverter"]}, {"title": "QRCodeScanner", "slug": "qrcodescanner", "components": ["QRCodeScanner"]}, {"title": "Captcha", "slug": "captcha", "components": ["Captcha"]}, {"title": "BarcodeDisplay · Signature · Rating Widget", "slug": "barcodedisplay-signature-rating-widget", "components": ["BarcodeDisplay", "Signature", "Rating Widget"]}, {"title": "Poll / Survey", "slug": "poll-survey", "components": ["Poll", "Survey"]}, {"title": "Quiz / Flashcard", "slug": "quiz-flashcard", "components": ["Quiz", "Flashcard"]}, {"title": "ChangeLog Viewer", "slug": "changelog-viewer", "components": ["ChangeLog Viewer"]}, {"title": "LicenseViewer", "slug": "licenseviewer", "components": ["LicenseViewer"]}, {"title": "Terms / Consent Dialog / CookieBanner", "slug": "terms-consent-dialog-cookiebanner", "components": ["Terms", "Consent Dialog", "CookieBanner"]}, {"title": "ExportDialog / ImportDialog / CSVImporter", "slug": "exportdialog-importdialog-csvimporter", "components": ["ExportDialog", "ImportDialog", "CSVImporter"]}, {"title": "WebView / HTMLPreview", "slug": "webview-htmlpreview", "components": ["WebView", "HTMLPreview"]}, {"title": "IframeEmbed / EmbedCard", "slug": "iframeembed-embedcard", "components": ["IframeEmbed", "EmbedCard"]}, {"title": "PrintButton · ShareSheet", "slug": "printbutton-sharesheet", "components": ["PrintButton", "ShareSheet"]}]}, -{"number": 43, "slug": "tooling", "title": "Library Tooling", "summary": "Tools for building with the library: this gallery, a playground that edits a component live, tools that inspect a running window, its boxes, frames, renders and input, and catalogs of its tokens, icons and contrast.", "stories": [{"title": "InspectorOverlay", "slug": "inspectoroverlay", "components": ["InspectorOverlay"]}, {"title": "FPSMeter / PerfOverlay", "slug": "fpsmeter-perfoverlay", "components": ["FPSMeter", "PerfOverlay"]}, {"title": "RenderCounter", "slug": "rendercounter", "components": ["RenderCounter"]}, {"title": "EventLogger", "slug": "eventlogger", "components": ["EventLogger"]}, {"title": "DesignTokenViewer", "slug": "designtokenviewer", "components": ["DesignTokenViewer"]}, {"title": "IconBrowser", "slug": "iconbrowser", "components": ["IconBrowser"]}, {"title": "A11yChecker", "slug": "a11ychecker", "components": ["A11yChecker"]}, {"title": "ScreenshotTest Harness", "slug": "screenshottest-harness", "components": ["ScreenshotTest Harness"]}, {"title": "ComponentGallery / Storybook", "slug": "componentgallery-storybook", "components": ["ComponentGallery", "Storybook"]}, {"title": "PlaygroundPanel", "slug": "playgroundpanel", "components": ["PlaygroundPanel"]}]} +{"number": 43, "slug": "tooling", "title": "Library Tooling", "summary": "Tools for building with the library: this gallery, a playground that edits a component live, tools that inspect a running window, its boxes, frames, renders and input, and catalogs of its tokens, icons and contrast.", "stories": [{"title": "InspectorOverlay", "slug": "inspectoroverlay", "components": ["InspectorOverlay"]}, {"title": "FPSMeter / PerfOverlay", "slug": "fpsmeter-perfoverlay", "components": ["FPSMeter", "PerfOverlay"]}, {"title": "RenderCounter", "slug": "rendercounter", "components": ["RenderCounter"]}, {"title": "EventLogger", "slug": "eventlogger", "components": ["EventLogger"]}, {"title": "DesignTokenViewer", "slug": "designtokenviewer", "components": ["DesignTokenViewer"]}, {"title": "IconBrowser", "slug": "iconbrowser", "components": ["IconBrowser"]}, {"title": "A11yChecker", "slug": "a11ychecker", "components": ["A11yChecker"]}, {"title": "ScreenshotTest Harness", "slug": "screenshottest-harness", "components": ["ScreenshotTest Harness"]}, {"title": "ComponentGallery / Storybook", "slug": "componentgallery-storybook", "components": ["ComponentGallery", "Storybook"]}, {"title": "PlaygroundPanel", "slug": "playgroundpanel", "components": ["PlaygroundPanel"]}]}, +{"number": 44, "slug": "rendering", "title": "Rendering", "summary": "Pixels the host draws at the box's size in device pixels, drawn again for a new size or content.", "stories": [{"title": "RenderSurface", "slug": "rendersurface", "components": ["RenderSurface", "ShaderView", "CameraTexture", "ZeroCopyTexture", "MetalSurface"]}]} ]} diff --git a/examples/gallery/swipe.rs b/examples/gallery/swipe.rs index df85f2e..ef8f070 100644 --- a/examples/gallery/swipe.rs +++ b/examples/gallery/swipe.rs @@ -89,3 +89,8 @@ fn post_scroll(x: f64, y: f64, across: f32, phase: i64, number: u32) -> Result<( } Ok(()) } + +#[cfg(not(target_os = "macos"))] +fn post_scroll(_: f64, _: f64, _: f32, _: i64, _: u32) -> Result<()> { + anyhow::bail!("scripted scrolls need macOS") +} diff --git a/scripts/stories.py b/scripts/stories.py index a8d14be..b6b4392 100644 --- a/scripts/stories.py +++ b/scripts/stories.py @@ -31,6 +31,9 @@ SHOWN = { ("theme", "Shape"): ["Elevation"], ("theme", "Motion"): ["Spring"], ("theme", "ThemeEditor"): ["ThemeProvider"], + ("rendering", "RenderSurface"): [ + "ShaderView", "CameraTexture", "ZeroCopyTexture", "MetalSurface", + ], } diff --git a/tasks/ch44.md b/tasks/ch44.md index 4296ef7..8825d59 100644 --- a/tasks/ch44.md +++ b/tasks/ch44.md @@ -4,10 +4,10 @@ gpui at 1a28cff paints pixels three ways: `RenderImage` through `paint_image` on every platform and the web; `CVPixelBuffer` through `paint_surface`, NV12 on macOS alone, dropped by the wgpu and DirectX renderers; a native view over the window. It shares no device and runs no outside shader. -- [ ] RenderSurface — the host draws a frame at the box's size in device pixels; a new size or revision draws again; the old frame leaves the atlas +- [x] RenderSurface — the host draws a frame at the box's size in device pixels; a new size or revision draws again; the old frame leaves the atlas - [x] FramebufferView / OffscreenRenderSurface / CustomRendererSurface / TextureView → `rendering::RenderSurface` -- [ ] ShaderView / GpuSurface / WebGPUSurface / Compute Visualization / GPU Particle View — `host`: the host renders on its own device and reads back into `rendering::RenderSurface` -- [ ] CameraTexture / GStreamerTexture / ExternalTexture — `host`: the host's frames reach `rendering::RenderSurface` -- [ ] ZeroCopyTexture / SharedTextureView — `blocked`: only macOS's Metal renderer draws `paint_surface`, NV12 alone -- [ ] MetalSurface / OpenGLSurface / VulkanSurface / D3D11/D3D12Surface — `blocked`: gpui shares no device; a host renders offscreen and feeds `rendering::RenderSurface` +- [x] ShaderView / GpuSurface / WebGPUSurface / Compute Visualization / GPU Particle View — `host`: the host renders on its own device and reads back into `rendering::RenderSurface` +- [x] CameraTexture / GStreamerTexture / ExternalTexture — `host`: the host's frames reach `rendering::RenderSurface` +- [x] ZeroCopyTexture / SharedTextureView — `blocked`: only macOS's Metal renderer draws `paint_surface`, NV12 alone +- [x] MetalSurface / OpenGLSurface / VulkanSurface / D3D11/D3D12Surface — `blocked`: gpui shares no device; a host renders offscreen and feeds `rendering::RenderSurface` - [x] 3D Scene / 3D Viewport → `media::ModelViewer` diff --git a/tasks/reviews.md b/tasks/reviews.md index a35d563..4013b76 100644 --- a/tasks/reviews.md +++ b/tasks/reviews.md @@ -353,3 +353,4 @@ | G3 | 2 | PASS | | | T50a | 1 | PASS | codex gpt-6.1-sol max | | T50b | 1 | PASS | | +| T50c | 1 | PASS | the gallery's missing non-macOS post_scroll stub added |