Skip to main content

guise/
gpuview.rs

1//! `GpuView` — a retained scene surface painted by gpui's GPU renderer.
2//!
3//! `GpuView` is for app-owned worlds that are more naturally expressed as a
4//! scene than a tree of controls: maps, editors, diagrams, simulations, and
5//! sprite-heavy status views. It keeps the scene API small, leaves animation
6//! state with the caller, and submits quads and textures through gpui's native
7//! paint pipeline. There is no web canvas or embedded browser involved.
8
9use std::sync::Arc;
10
11use gpui::prelude::*;
12use gpui::{
13  canvas, fill, px, quad, size, App, BorderStyle, Bounds, ContentMask, Corners, Edges, Hsla,
14  ImageFormat, IntoElement, Pixels, RenderImage, Window,
15};
16
17use crate::devtools::Probed;
18use crate::theme::theme;
19
20/// How a scene's logical coordinate space maps into the view bounds.
21#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
22pub enum GpuFit {
23  /// Preserve aspect ratio and show the whole scene, letterboxing as needed.
24  #[default]
25  Contain,
26  /// Preserve aspect ratio and fill the view, clipping the excess.
27  Cover,
28  /// Scale each axis independently to fill the view.
29  Stretch,
30}
31
32/// A rectangle in the scene's logical coordinate space.
33#[derive(Clone, Copy, Debug, Default, PartialEq)]
34pub struct GpuRect {
35  pub x: f32,
36  pub y: f32,
37  pub width: f32,
38  pub height: f32,
39}
40
41impl GpuRect {
42  pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
43    Self {
44      x,
45      y,
46      width,
47      height,
48    }
49  }
50}
51
52/// Encoded image data retained by a [`GpuScene`].
53///
54/// gpui decodes it through the normal asset cache and uploads the result to
55/// its sprite atlas. Cloning a texture is cheap.
56#[derive(Clone, Debug)]
57pub struct GpuTexture {
58  image: Arc<gpui::Image>,
59}
60
61impl GpuTexture {
62  pub fn from_encoded(format: ImageFormat, bytes: impl Into<Vec<u8>>) -> Self {
63    Self {
64      image: Arc::new(gpui::Image::from_bytes(format, bytes.into())),
65    }
66  }
67
68  pub fn png(bytes: impl Into<Vec<u8>>) -> Self {
69    Self::from_encoded(ImageFormat::Png, bytes)
70  }
71
72  pub fn jpeg(bytes: impl Into<Vec<u8>>) -> Self {
73    Self::from_encoded(ImageFormat::Jpeg, bytes)
74  }
75
76  pub fn webp(bytes: impl Into<Vec<u8>>) -> Self {
77    Self::from_encoded(ImageFormat::Webp, bytes)
78  }
79}
80
81#[derive(Clone, Debug)]
82struct GpuQuad {
83  bounds: GpuRect,
84  fill: Hsla,
85  radius: f32,
86  border_width: f32,
87  border: Hsla,
88}
89
90#[derive(Clone, Debug)]
91enum GpuCommand {
92  Quad(GpuQuad),
93  Texture {
94    texture: GpuTexture,
95    bounds: GpuRect,
96    source: Option<GpuRect>,
97  },
98}
99
100/// A retained list of GPU-friendly drawing commands in a logical coordinate
101/// space. Build or update it with application state, then pass it to
102/// [`GpuView`]. Commands paint in insertion order.
103#[derive(Clone, Debug)]
104pub struct GpuScene {
105  width: f32,
106  height: f32,
107  commands: Vec<GpuCommand>,
108}
109
110impl GpuScene {
111  pub fn new(width: f32, height: f32) -> Self {
112    Self {
113      width: finite_positive(width),
114      height: finite_positive(height),
115      commands: Vec::new(),
116    }
117  }
118
119  pub fn size(&self) -> (f32, f32) {
120    (self.width, self.height)
121  }
122
123  pub fn len(&self) -> usize {
124    self.commands.len()
125  }
126
127  pub fn is_empty(&self) -> bool {
128    self.commands.is_empty()
129  }
130
131  /// Append a filled rectangle.
132  pub fn rect(mut self, bounds: GpuRect, color: Hsla) -> Self {
133    self.push_rect(bounds, color);
134    self
135  }
136
137  /// Append a filled rounded rectangle.
138  pub fn rounded_rect(mut self, bounds: GpuRect, color: Hsla, radius: f32) -> Self {
139    self.commands.push(GpuCommand::Quad(GpuQuad {
140      bounds: sane_rect(bounds),
141      fill: color,
142      radius: finite_nonnegative(radius),
143      border_width: 0.0,
144      border: gpui::transparent_black(),
145    }));
146    self
147  }
148
149  /// Append a filled rectangle with a solid border.
150  pub fn bordered_rect(
151    mut self,
152    bounds: GpuRect,
153    fill: Hsla,
154    border: Hsla,
155    border_width: f32,
156    radius: f32,
157  ) -> Self {
158    self.commands.push(GpuCommand::Quad(GpuQuad {
159      bounds: sane_rect(bounds),
160      fill,
161      radius: finite_nonnegative(radius),
162      border_width: finite_nonnegative(border_width),
163      border,
164    }));
165    self
166  }
167
168  pub fn push_rect(&mut self, bounds: GpuRect, color: Hsla) {
169    self.commands.push(GpuCommand::Quad(GpuQuad {
170      bounds: sane_rect(bounds),
171      fill: color,
172      radius: 0.0,
173      border_width: 0.0,
174      border: gpui::transparent_black(),
175    }));
176  }
177
178  /// Append a texture mapped over the supplied scene rectangle.
179  pub fn texture(mut self, texture: GpuTexture, bounds: GpuRect) -> Self {
180    self.push_texture(texture, bounds);
181    self
182  }
183
184  /// Draw one normalized region of a texture into a scene rectangle.
185  ///
186  /// `source` uses `0.0..=1.0` texture coordinates. This keeps sprite-sheet
187  /// animation app-owned: select a frame while rebuilding the scene, without
188  /// creating a new texture or decoding the atlas again.
189  pub fn sprite(mut self, texture: GpuTexture, source: GpuRect, bounds: GpuRect) -> Self {
190    self.push_sprite(texture, source, bounds);
191    self
192  }
193
194  /// Append a texture covering the scene's full logical bounds.
195  pub fn background(self, texture: GpuTexture) -> Self {
196    let bounds = GpuRect::new(0.0, 0.0, self.width, self.height);
197    self.texture(texture, bounds)
198  }
199
200  pub fn push_texture(&mut self, texture: GpuTexture, bounds: GpuRect) {
201    self.commands.push(GpuCommand::Texture {
202      texture,
203      bounds: sane_rect(bounds),
204      source: None,
205    });
206  }
207
208  pub fn push_sprite(&mut self, texture: GpuTexture, source: GpuRect, bounds: GpuRect) {
209    self.commands.push(GpuCommand::Texture {
210      texture,
211      bounds: sane_rect(bounds),
212      source: Some(sane_source_rect(source)),
213    });
214  }
215}
216
217/// A stateless scene component backed by gpui's native GPU paint pipeline.
218///
219/// The caller owns simulation and animation state. Rebuild the lightweight
220/// [`GpuScene`] and notify the parent entity when a frame changes.
221#[derive(IntoElement)]
222pub struct GpuView {
223  scene: GpuScene,
224  fit: GpuFit,
225  width: Option<f32>,
226  height: Option<f32>,
227  background: Option<Hsla>,
228  pixel_snap: bool,
229}
230
231impl GpuView {
232  pub fn new(scene: GpuScene) -> Self {
233    Self {
234      scene,
235      fit: GpuFit::Contain,
236      width: None,
237      height: Some(240.0),
238      background: None,
239      pixel_snap: false,
240    }
241  }
242
243  pub fn fit(mut self, fit: GpuFit) -> Self {
244    self.fit = fit;
245    self
246  }
247
248  pub fn width(mut self, width: f32) -> Self {
249    self.width = Some(finite_positive(width));
250    self
251  }
252
253  pub fn height(mut self, height: f32) -> Self {
254    self.height = Some(finite_positive(height));
255    self
256  }
257
258  /// Stretch to the parent's available height instead of using a fixed one.
259  pub fn full_height(mut self) -> Self {
260    self.height = None;
261    self
262  }
263
264  /// Color behind uncovered areas. Defaults to the theme surface.
265  pub fn background(mut self, color: Hsla) -> Self {
266    self.background = Some(color);
267    self
268  }
269
270  /// Snap transformed command bounds to physical pixel boundaries. Useful
271  /// for tile maps and pixel art.
272  pub fn pixelated(mut self) -> Self {
273    self.pixel_snap = true;
274    self
275  }
276}
277
278impl RenderOnce for GpuView {
279  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
280    let background = self
281      .background
282      .unwrap_or_else(|| theme(cx).surface().hsla());
283    let scene = Arc::new(self.scene);
284    let prepaint_scene = scene.clone();
285    let fit = self.fit;
286    let pixel_snap = self.pixel_snap;
287
288    let mut surface = canvas(
289      move |_, window, cx| {
290        prepaint_scene
291          .commands
292          .iter()
293          .filter_map(|command| match command {
294            GpuCommand::Texture { texture, .. } => {
295              Some(texture.image.clone().use_render_image(window, cx))
296            }
297            GpuCommand::Quad(_) => None,
298          })
299          .collect::<Vec<Option<Arc<RenderImage>>>>()
300      },
301      move |bounds, images, window, _cx| {
302        window.paint_quad(fill(bounds, background));
303        let mut images = images.iter();
304        let transform = SceneTransform::new(
305          scene.width,
306          scene.height,
307          f32::from(bounds.size.width),
308          f32::from(bounds.size.height),
309          fit,
310        );
311        window.with_content_mask(Some(ContentMask { bounds }), |window| {
312          for command in &scene.commands {
313            match command {
314              GpuCommand::Quad(command) => {
315                let command_bounds = transform.bounds(bounds, command.bounds, pixel_snap);
316                let radius = transform.radius(command.radius, pixel_snap);
317                let border_width = transform.radius(command.border_width, pixel_snap);
318                window.paint_quad(quad(
319                  command_bounds,
320                  Corners::all(px(radius)),
321                  command.fill,
322                  Edges::all(px(border_width)),
323                  command.border,
324                  BorderStyle::Solid,
325                ));
326              }
327              GpuCommand::Texture {
328                bounds: destination,
329                source,
330                ..
331              } => {
332                let Some(image) = images.next().and_then(Clone::clone) else {
333                  continue;
334                };
335                let image_bounds = transform.bounds(bounds, *destination, pixel_snap);
336                if let Some(source) = source {
337                  let full_width = f32::from(image_bounds.size.width) / source.width;
338                  let full_height = f32::from(image_bounds.size.height) / source.height;
339                  let atlas_bounds = Bounds::new(
340                    image_bounds.origin
341                      - gpui::point(px(source.x * full_width), px(source.y * full_height)),
342                    size(px(full_width), px(full_height)),
343                  );
344                  window.with_content_mask(
345                    Some(ContentMask {
346                      bounds: image_bounds,
347                    }),
348                    |window| {
349                      let _ = window.paint_image(atlas_bounds, Corners::default(), image, 0, false);
350                    },
351                  );
352                } else {
353                  let _ = window.paint_image(image_bounds, Corners::default(), image, 0, false);
354                }
355              }
356            }
357          }
358        });
359      },
360    )
361    .overflow_hidden();
362
363    surface = match self.height {
364      Some(height) => surface.h(px(height)),
365      None => surface.h_full(),
366    };
367
368    match self.width {
369      Some(width) => surface.w(px(width)).probe("GpuView"),
370      None => surface.w_full().probe("GpuView"),
371    }
372  }
373}
374
375#[derive(Clone, Copy, Debug, PartialEq)]
376struct SceneTransform {
377  scale_x: f32,
378  scale_y: f32,
379  offset_x: f32,
380  offset_y: f32,
381}
382
383impl SceneTransform {
384  fn new(scene_w: f32, scene_h: f32, view_w: f32, view_h: f32, fit: GpuFit) -> Self {
385    let sx = finite_positive(view_w) / finite_positive(scene_w);
386    let sy = finite_positive(view_h) / finite_positive(scene_h);
387    let (scale_x, scale_y) = match fit {
388      GpuFit::Contain => {
389        let scale = sx.min(sy);
390        (scale, scale)
391      }
392      GpuFit::Cover => {
393        let scale = sx.max(sy);
394        (scale, scale)
395      }
396      GpuFit::Stretch => (sx, sy),
397    };
398    Self {
399      scale_x,
400      scale_y,
401      offset_x: (view_w - scene_w * scale_x) * 0.5,
402      offset_y: (view_h - scene_h * scale_y) * 0.5,
403    }
404  }
405
406  fn bounds(self, viewport: Bounds<Pixels>, source: GpuRect, pixel_snap: bool) -> Bounds<Pixels> {
407    let x = self.offset_x + source.x * self.scale_x;
408    let y = self.offset_y + source.y * self.scale_y;
409    let width = source.width * self.scale_x;
410    let height = source.height * self.scale_y;
411    let snap = |value: f32| if pixel_snap { value.round() } else { value };
412    Bounds::new(
413      viewport.origin + gpui::point(px(snap(x)), px(snap(y))),
414      size(px(snap(width)), px(snap(height))),
415    )
416  }
417
418  fn radius(self, value: f32, pixel_snap: bool) -> f32 {
419    let scaled = value * self.scale_x.min(self.scale_y);
420    if pixel_snap {
421      scaled.round()
422    } else {
423      scaled
424    }
425  }
426}
427
428fn finite_positive(value: f32) -> f32 {
429  if value.is_finite() && value > 0.0 {
430    value
431  } else {
432    1.0
433  }
434}
435
436fn finite_nonnegative(value: f32) -> f32 {
437  if value.is_finite() {
438    value.max(0.0)
439  } else {
440    0.0
441  }
442}
443
444fn sane_rect(rect: GpuRect) -> GpuRect {
445  GpuRect {
446    x: if rect.x.is_finite() { rect.x } else { 0.0 },
447    y: if rect.y.is_finite() { rect.y } else { 0.0 },
448    width: finite_nonnegative(rect.width),
449    height: finite_nonnegative(rect.height),
450  }
451}
452
453fn sane_source_rect(rect: GpuRect) -> GpuRect {
454  let x = if rect.x.is_finite() {
455    rect.x.clamp(0.0, 1.0 - f32::EPSILON)
456  } else {
457    0.0
458  };
459  let y = if rect.y.is_finite() {
460    rect.y.clamp(0.0, 1.0 - f32::EPSILON)
461  } else {
462    0.0
463  };
464  let width = if rect.width.is_finite() {
465    rect.width.clamp(f32::EPSILON, 1.0 - x)
466  } else {
467    1.0 - x
468  };
469  let height = if rect.height.is_finite() {
470    rect.height.clamp(f32::EPSILON, 1.0 - y)
471  } else {
472    1.0 - y
473  };
474  GpuRect::new(x, y, width, height)
475}
476
477#[cfg(test)]
478mod tests {
479  use super::*;
480
481  #[test]
482  fn contain_centers_the_letterboxed_scene() {
483    let transform = SceneTransform::new(200.0, 100.0, 100.0, 100.0, GpuFit::Contain);
484    assert_eq!(transform.scale_x, 0.5);
485    assert_eq!(transform.scale_y, 0.5);
486    assert_eq!(transform.offset_x, 0.0);
487    assert_eq!(transform.offset_y, 25.0);
488  }
489
490  #[test]
491  fn cover_centers_the_clipped_scene() {
492    let transform = SceneTransform::new(200.0, 100.0, 100.0, 100.0, GpuFit::Cover);
493    assert_eq!(transform.scale_x, 1.0);
494    assert_eq!(transform.scale_y, 1.0);
495    assert_eq!(transform.offset_x, -50.0);
496    assert_eq!(transform.offset_y, 0.0);
497  }
498
499  #[test]
500  fn stretch_maps_each_axis_independently() {
501    let transform = SceneTransform::new(200.0, 100.0, 100.0, 100.0, GpuFit::Stretch);
502    assert_eq!(transform.scale_x, 0.5);
503    assert_eq!(transform.scale_y, 1.0);
504    assert_eq!(transform.offset_x, 0.0);
505    assert_eq!(transform.offset_y, 0.0);
506  }
507
508  #[test]
509  fn scene_sanitizes_non_finite_geometry() {
510    let scene = GpuScene::new(f32::NAN, 0.0).rect(
511      GpuRect::new(f32::INFINITY, 2.0, -3.0, f32::NAN),
512      gpui::black(),
513    );
514    assert_eq!(scene.size(), (1.0, 1.0));
515    let GpuCommand::Quad(command) = &scene.commands[0] else {
516      panic!("expected quad");
517    };
518    assert_eq!(command.bounds, GpuRect::new(0.0, 2.0, 0.0, 0.0));
519  }
520
521  #[test]
522  fn sprite_sources_are_clamped_to_the_texture() {
523    assert_eq!(
524      sane_source_rect(GpuRect::new(0.75, -1.0, 0.5, f32::NAN)),
525      GpuRect::new(0.75, 0.0, 0.25, 1.0)
526    );
527  }
528}