gpui: Add `opacity` to support transparency of the entire element (#17132)

Jason Lee created

Release Notes:

- N/A

---

Add this for let GPUI element to support fade in-out animation.

## Platform test

- [x] macOS
- [x] blade `cargo run -p gpui --example opacity --features macos-blade`

## Usage

```rs
div()
    .opacity(0.5)
    .bg(gpui::black())
    .text_color(gpui::black())
    .child("Hello world")
```

This will apply the `opacity` it self and all children to use `opacity`
value to render colors.

## Example

```
cargo run -p gpui --example opacity
cargo run -p gpui --example opacity --features macos-blade
```

<img width="612" alt="image"
src="https://github.com/user-attachments/assets/f1da87ed-31f5-4b55-a023-39e8ee1ba349">

Change summary

crates/gpui/Cargo.toml                      |   4 
crates/gpui/examples/opacity.rs             | 173 +++++++++++++++++++++++
crates/gpui/src/color.rs                    |  10 +
crates/gpui/src/elements/div.rs             |  61 ++++---
crates/gpui/src/platform/blade/shaders.wgsl |   4 
crates/gpui/src/platform/mac/shaders.metal  |   2 
crates/gpui/src/scene.rs                    |   5 
crates/gpui/src/style.rs                    |   4 
crates/gpui/src/styled.rs                   |   6 
crates/gpui/src/window.rs                   |  66 +++++++-
10 files changed, 298 insertions(+), 37 deletions(-)

Detailed changes

crates/gpui/Cargo.toml 🔗

@@ -188,3 +188,7 @@ path = "examples/svg/svg.rs"
 [[example]]
 name = "text_wrapper"
 path = "examples/text_wrapper.rs"
+
+[[example]]
+name = "opacity"
+path = "examples/opacity.rs"

crates/gpui/examples/opacity.rs 🔗

@@ -0,0 +1,173 @@
+use std::{fs, path::PathBuf, time::Duration};
+
+use gpui::*;
+
+struct Assets {
+    base: PathBuf,
+}
+
+impl AssetSource for Assets {
+    fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
+        fs::read(self.base.join(path))
+            .map(|data| Some(std::borrow::Cow::Owned(data)))
+            .map_err(|e| e.into())
+    }
+
+    fn list(&self, path: &str) -> Result<Vec<SharedString>> {
+        fs::read_dir(self.base.join(path))
+            .map(|entries| {
+                entries
+                    .filter_map(|entry| {
+                        entry
+                            .ok()
+                            .and_then(|entry| entry.file_name().into_string().ok())
+                            .map(SharedString::from)
+                    })
+                    .collect()
+            })
+            .map_err(|e| e.into())
+    }
+}
+
+struct HelloWorld {
+    _task: Option<Task<()>>,
+    opacity: f32,
+}
+
+impl HelloWorld {
+    fn new(_: &mut ViewContext<Self>) -> Self {
+        Self {
+            _task: None,
+            opacity: 0.5,
+        }
+    }
+
+    fn change_opacity(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
+        self.opacity = 0.0;
+        cx.notify();
+
+        self._task = Some(cx.spawn(|view, mut cx| async move {
+            loop {
+                Timer::after(Duration::from_secs_f32(0.05)).await;
+                let mut stop = false;
+                let _ = cx.update(|cx| {
+                    view.update(cx, |view, cx| {
+                        if view.opacity >= 1.0 {
+                            stop = true;
+                            return;
+                        }
+
+                        view.opacity += 0.1;
+                        cx.notify();
+                    })
+                });
+
+                if stop {
+                    break;
+                }
+            }
+
+            ()
+        }));
+    }
+}
+
+impl Render for HelloWorld {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div()
+            .flex()
+            .flex_row()
+            .size_full()
+            .bg(rgb(0xE0E0E0))
+            .text_xl()
+            .child(
+                div()
+                    .flex()
+                    .size_full()
+                    .justify_center()
+                    .items_center()
+                    .border_1()
+                    .text_color(gpui::blue())
+                    .child(div().child("This is background text.")),
+            )
+            .child(
+                div()
+                    .id("panel")
+                    .on_click(cx.listener(Self::change_opacity))
+                    .absolute()
+                    .top_8()
+                    .left_8()
+                    .right_8()
+                    .bottom_8()
+                    .opacity(self.opacity)
+                    .flex()
+                    .justify_center()
+                    .items_center()
+                    .bg(gpui::white())
+                    .border_3()
+                    .border_color(gpui::red())
+                    .text_color(gpui::yellow())
+                    .child(
+                        div()
+                            .flex()
+                            .flex_col()
+                            .gap_2()
+                            .justify_center()
+                            .items_center()
+                            .size(px(300.))
+                            .bg(gpui::blue())
+                            .border_3()
+                            .border_color(gpui::black())
+                            .shadow(smallvec::smallvec![BoxShadow {
+                                color: hsla(0.0, 0.0, 0.0, 0.5),
+                                blur_radius: px(1.0),
+                                spread_radius: px(5.0),
+                                offset: point(px(10.0), px(10.0)),
+                            }])
+                            .child(img("image/app-icon.png").size_8())
+                            .child("Opacity Panel (Click to test)")
+                            .child(
+                                div()
+                                    .id("deep-level-text")
+                                    .flex()
+                                    .justify_center()
+                                    .items_center()
+                                    .p_4()
+                                    .bg(gpui::black())
+                                    .text_color(gpui::white())
+                                    .text_decoration_2()
+                                    .text_decoration_wavy()
+                                    .text_decoration_color(gpui::red())
+                                    .child(format!("opacity: {:.1}", self.opacity)),
+                            )
+                            .child(
+                                svg()
+                                    .path("image/arrow_circle.svg")
+                                    .text_color(gpui::black())
+                                    .text_2xl()
+                                    .size_8(),
+                            )
+                            .child("🎊✈️🎉🎈🎁🎂")
+                            .child(img("image/black-cat-typing.gif").size_12()),
+                    ),
+            )
+    }
+}
+
+fn main() {
+    App::new()
+        .with_assets(Assets {
+            base: PathBuf::from("crates/gpui/examples"),
+        })
+        .run(|cx: &mut AppContext| {
+            let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    ..Default::default()
+                },
+                |cx| cx.new_view(HelloWorld::new),
+            )
+            .unwrap();
+        });
+}

crates/gpui/src/color.rs 🔗

@@ -461,6 +461,16 @@ impl Hsla {
     pub fn fade_out(&mut self, factor: f32) {
         self.a *= 1.0 - factor.clamp(0., 1.);
     }
+
+    /// Returns a new HSLA color with the same hue, saturation, and lightness, but with a modified alpha value.
+    pub fn opacity(&self, factor: f32) -> Self {
+        Hsla {
+            h: self.h,
+            s: self.s,
+            l: self.l,
+            a: self.a * factor.clamp(0., 1.),
+        }
+    }
 }
 
 impl From<Rgba> for Hsla {

crates/gpui/src/elements/div.rs 🔗

@@ -1500,35 +1500,44 @@ impl Interactivity {
                     return ((), element_state);
                 }
 
-                style.paint(bounds, cx, |cx: &mut WindowContext| {
-                    cx.with_text_style(style.text_style().cloned(), |cx| {
-                        cx.with_content_mask(style.overflow_mask(bounds, cx.rem_size()), |cx| {
-                            if let Some(hitbox) = hitbox {
-                                #[cfg(debug_assertions)]
-                                self.paint_debug_info(global_id, hitbox, &style, cx);
-
-                                if !cx.has_active_drag() {
-                                    if let Some(mouse_cursor) = style.mouse_cursor {
-                                        cx.set_cursor_style(mouse_cursor, hitbox);
+                cx.with_element_opacity(style.opacity, |cx| {
+                    style.paint(bounds, cx, |cx: &mut WindowContext| {
+                        cx.with_text_style(style.text_style().cloned(), |cx| {
+                            cx.with_content_mask(
+                                style.overflow_mask(bounds, cx.rem_size()),
+                                |cx| {
+                                    if let Some(hitbox) = hitbox {
+                                        #[cfg(debug_assertions)]
+                                        self.paint_debug_info(global_id, hitbox, &style, cx);
+
+                                        if !cx.has_active_drag() {
+                                            if let Some(mouse_cursor) = style.mouse_cursor {
+                                                cx.set_cursor_style(mouse_cursor, hitbox);
+                                            }
+                                        }
+
+                                        if let Some(group) = self.group.clone() {
+                                            GroupHitboxes::push(group, hitbox.id, cx);
+                                        }
+
+                                        self.paint_mouse_listeners(
+                                            hitbox,
+                                            element_state.as_mut(),
+                                            cx,
+                                        );
+                                        self.paint_scroll_listener(hitbox, &style, cx);
                                     }
-                                }
-
-                                if let Some(group) = self.group.clone() {
-                                    GroupHitboxes::push(group, hitbox.id, cx);
-                                }
-
-                                self.paint_mouse_listeners(hitbox, element_state.as_mut(), cx);
-                                self.paint_scroll_listener(hitbox, &style, cx);
-                            }
 
-                            self.paint_keyboard_listeners(cx);
-                            f(&style, cx);
+                                    self.paint_keyboard_listeners(cx);
+                                    f(&style, cx);
 
-                            if hitbox.is_some() {
-                                if let Some(group) = self.group.as_ref() {
-                                    GroupHitboxes::pop(group, cx);
-                                }
-                            }
+                                    if hitbox.is_some() {
+                                        if let Some(group) = self.group.as_ref() {
+                                            GroupHitboxes::pop(group, cx);
+                                        }
+                                    }
+                                },
+                            );
                         });
                     });
                 });

crates/gpui/src/platform/blade/shaders.wgsl 🔗

@@ -548,7 +548,9 @@ fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4<f32> {
 
 struct PolychromeSprite {
     order: u32,
+    pad: u32,
     grayscale: u32,
+    opacity: f32,
     bounds: Bounds,
     content_mask: Bounds,
     corner_radii: Corners,
@@ -592,7 +594,7 @@ fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4<f32> {
         let grayscale = dot(color.rgb, GRAYSCALE_FACTORS);
         color = vec4<f32>(vec3<f32>(grayscale), sample.a);
     }
-    return blend_color(color, saturate(0.5 - distance));
+    return blend_color(color, sprite.opacity * saturate(0.5 - distance));
 }
 
 // --- surfaces --- //

crates/gpui/src/platform/mac/shaders.metal 🔗

@@ -385,7 +385,7 @@ fragment float4 polychrome_sprite_fragment(
     color.g = grayscale;
     color.b = grayscale;
   }
-  color.a *= saturate(0.5 - distance);
+  color.a *= sprite.opacity * saturate(0.5 - distance);
   return color;
 }
 

crates/gpui/src/scene.rs 🔗

@@ -640,16 +640,19 @@ impl From<MonochromeSprite> for Primitive {
     }
 }
 
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
 #[repr(C)]
 pub(crate) struct PolychromeSprite {
     pub order: DrawOrder,
+    pub pad: u32, // align to 8 bytes
     pub grayscale: bool,
+    pub opacity: f32,
     pub bounds: Bounds<ScaledPixels>,
     pub content_mask: ContentMask<ScaledPixels>,
     pub corner_radii: Corners<ScaledPixels>,
     pub tile: AtlasTile,
 }
+impl Eq for PolychromeSprite {}
 
 impl Ord for PolychromeSprite {
     fn cmp(&self, other: &Self) -> std::cmp::Ordering {

crates/gpui/src/style.rs 🔗

@@ -234,6 +234,9 @@ pub struct Style {
     /// The mouse cursor style shown when the mouse pointer is over an element.
     pub mouse_cursor: Option<CursorStyle>,
 
+    /// The opacity of this element
+    pub opacity: Option<f32>,
+
     /// Whether to draw a red debugging outline around this element
     #[cfg(debug_assertions)]
     pub debug: bool,
@@ -694,6 +697,7 @@ impl Default for Style {
             box_shadow: Default::default(),
             text: TextStyleRefinement::default(),
             mouse_cursor: None,
+            opacity: None,
 
             #[cfg(debug_assertions)]
             debug: false,

crates/gpui/src/styled.rs 🔗

@@ -547,6 +547,12 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Set opacity on this element and its children.
+    fn opacity(mut self, opacity: f32) -> Self {
+        self.style().opacity = Some(opacity);
+        self
+    }
+
     /// Draw a debug border around this element.
     #[cfg(debug_assertions)]
     fn debug(mut self) -> Self {

crates/gpui/src/window.rs 🔗

@@ -520,6 +520,7 @@ pub struct Window {
     pub(crate) element_id_stack: SmallVec<[ElementId; 32]>,
     pub(crate) text_style_stack: Vec<TextStyleRefinement>,
     pub(crate) element_offset_stack: Vec<Point<Pixels>>,
+    pub(crate) element_opacity: Option<f32>,
     pub(crate) content_mask_stack: Vec<ContentMask<Pixels>>,
     pub(crate) requested_autoscroll: Option<Bounds<Pixels>>,
     pub(crate) rendered_frame: Frame,
@@ -799,6 +800,7 @@ impl Window {
             text_style_stack: Vec::new(),
             element_offset_stack: Vec::new(),
             content_mask_stack: Vec::new(),
+            element_opacity: None,
             requested_autoscroll: None,
             rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
             next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
@@ -1908,6 +1910,28 @@ impl<'a> WindowContext<'a> {
         result
     }
 
+    pub(crate) fn with_element_opacity<R>(
+        &mut self,
+        opacity: Option<f32>,
+        f: impl FnOnce(&mut Self) -> R,
+    ) -> R {
+        if opacity.is_none() {
+            return f(self);
+        }
+
+        debug_assert!(
+            matches!(
+                self.window.draw_phase,
+                DrawPhase::Prepaint | DrawPhase::Paint
+            ),
+            "this method can only be called during prepaint, or paint"
+        );
+        self.window_mut().element_opacity = opacity;
+        let result = f(self);
+        self.window_mut().element_opacity = None;
+        result
+    }
+
     /// Perform prepaint on child elements in a "retryable" manner, so that any side effects
     /// of prepaints can be discarded before prepainting again. This is used to support autoscroll
     /// where we need to prepaint children to detect the autoscroll bounds, then adjust the
@@ -2021,6 +2045,19 @@ impl<'a> WindowContext<'a> {
             .unwrap_or_default()
     }
 
+    /// Obtain the current element opacity. This method should only be called during the
+    /// prepaint phase of element drawing.
+    pub(crate) fn element_opacity(&self) -> f32 {
+        debug_assert!(
+            matches!(
+                self.window.draw_phase,
+                DrawPhase::Prepaint | DrawPhase::Paint
+            ),
+            "this method can only be called during prepaint, or paint"
+        );
+        self.window().element_opacity.unwrap_or(1.0)
+    }
+
     /// Obtain the current content mask. This method should only be called during element drawing.
     pub fn content_mask(&self) -> ContentMask<Pixels> {
         debug_assert!(
@@ -2258,6 +2295,7 @@ impl<'a> WindowContext<'a> {
 
         let scale_factor = self.scale_factor();
         let content_mask = self.content_mask();
+        let opacity = self.element_opacity();
         for shadow in shadows {
             let mut shadow_bounds = bounds;
             shadow_bounds.origin += shadow.offset;
@@ -2268,7 +2306,7 @@ impl<'a> WindowContext<'a> {
                 bounds: shadow_bounds.scale(scale_factor),
                 content_mask: content_mask.scale(scale_factor),
                 corner_radii: corner_radii.scale(scale_factor),
-                color: shadow.color,
+                color: shadow.color.opacity(opacity),
             });
         }
     }
@@ -2287,13 +2325,14 @@ impl<'a> WindowContext<'a> {
 
         let scale_factor = self.scale_factor();
         let content_mask = self.content_mask();
+        let opacity = self.element_opacity();
         self.window.next_frame.scene.insert_primitive(Quad {
             order: 0,
             pad: 0,
             bounds: quad.bounds.scale(scale_factor),
             content_mask: content_mask.scale(scale_factor),
-            background: quad.background,
-            border_color: quad.border_color,
+            background: quad.background.opacity(opacity),
+            border_color: quad.border_color.opacity(opacity),
             corner_radii: quad.corner_radii.scale(scale_factor),
             border_widths: quad.border_widths.scale(scale_factor),
         });
@@ -2311,8 +2350,9 @@ impl<'a> WindowContext<'a> {
 
         let scale_factor = self.scale_factor();
         let content_mask = self.content_mask();
+        let opacity = self.element_opacity();
         path.content_mask = content_mask;
-        path.color = color.into();
+        path.color = color.into().opacity(opacity);
         self.window
             .next_frame
             .scene
@@ -2345,13 +2385,14 @@ impl<'a> WindowContext<'a> {
             size: size(width, height),
         };
         let content_mask = self.content_mask();
+        let element_opacity = self.element_opacity();
 
         self.window.next_frame.scene.insert_primitive(Underline {
             order: 0,
             pad: 0,
             bounds: bounds.scale(scale_factor),
             content_mask: content_mask.scale(scale_factor),
-            color: style.color.unwrap_or_default(),
+            color: style.color.unwrap_or_default().opacity(element_opacity),
             thickness: style.thickness.scale(scale_factor),
             wavy: style.wavy,
         });
@@ -2379,6 +2420,7 @@ impl<'a> WindowContext<'a> {
             size: size(width, height),
         };
         let content_mask = self.content_mask();
+        let opacity = self.element_opacity();
 
         self.window.next_frame.scene.insert_primitive(Underline {
             order: 0,
@@ -2386,7 +2428,7 @@ impl<'a> WindowContext<'a> {
             bounds: bounds.scale(scale_factor),
             content_mask: content_mask.scale(scale_factor),
             thickness: style.thickness.scale(scale_factor),
-            color: style.color.unwrap_or_default(),
+            color: style.color.unwrap_or_default().opacity(opacity),
             wavy: false,
         });
     }
@@ -2413,6 +2455,7 @@ impl<'a> WindowContext<'a> {
             "this method can only be called during paint"
         );
 
+        let element_opacity = self.element_opacity();
         let scale_factor = self.scale_factor();
         let glyph_origin = origin.scale(scale_factor);
         let subpixel_variant = Point {
@@ -2451,7 +2494,7 @@ impl<'a> WindowContext<'a> {
                     pad: 0,
                     bounds,
                     content_mask,
-                    color,
+                    color: color.opacity(element_opacity),
                     tile,
                     transformation: TransformationMatrix::unit(),
                 });
@@ -2508,17 +2551,20 @@ impl<'a> WindowContext<'a> {
                 size: tile.bounds.size.map(Into::into),
             };
             let content_mask = self.content_mask().scale(scale_factor);
+            let opacity = self.element_opacity();
 
             self.window
                 .next_frame
                 .scene
                 .insert_primitive(PolychromeSprite {
                     order: 0,
+                    pad: 0,
                     grayscale: false,
                     bounds,
                     corner_radii: Default::default(),
                     content_mask,
                     tile,
+                    opacity,
                 });
         }
         Ok(())
@@ -2540,6 +2586,7 @@ impl<'a> WindowContext<'a> {
             "this method can only be called during paint"
         );
 
+        let element_opacity = self.element_opacity();
         let scale_factor = self.scale_factor();
         let bounds = bounds.scale(scale_factor);
         // Render the SVG at twice the size to get a higher quality result.
@@ -2574,7 +2621,7 @@ impl<'a> WindowContext<'a> {
                     .map_origin(|origin| origin.floor())
                     .map_size(|size| size.ceil()),
                 content_mask,
-                color,
+                color: color.opacity(element_opacity),
                 tile,
                 transformation,
             });
@@ -2622,17 +2669,20 @@ impl<'a> WindowContext<'a> {
             .expect("Callback above only returns Some");
         let content_mask = self.content_mask().scale(scale_factor);
         let corner_radii = corner_radii.scale(scale_factor);
+        let opacity = self.element_opacity();
 
         self.window
             .next_frame
             .scene
             .insert_primitive(PolychromeSprite {
                 order: 0,
+                pad: 0,
                 grayscale,
                 bounds,
                 content_mask,
                 corner_radii,
                 tile,
+                opacity,
             });
         Ok(())
     }