gpui: Fix broken rendering with nested opacity (#35407)

Cyandev created

Rendering breaks when both an element and its parent have opacity set.
The following code reproduces the issue:

```rust
struct Repro;

impl Render for Repro {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        fn make_box(bg: impl Into<Fill>) -> impl IntoElement {
            div().size_8().bg(bg).hover(|style| style.opacity(0.5))
        }

        div()
            .flex()
            .items_center()
            .justify_center()
            .size(px(500.0))
            .hover(|style| style.opacity(0.5))
            .child(make_box(gpui::red()))
            .child(make_box(gpui::green()))
            .child(make_box(gpui::blue()))
    }
}
```

Before (broken behavior):


https://github.com/user-attachments/assets/2c5c1e31-88b2-4f39-81f8-40060e3fe958

The child element resets its parent and siblings' opacity, which is an
unexpected behavior.

After (fixed behavior):


https://github.com/user-attachments/assets/48527033-b06f-4737-b6c3-0ee3d133f138

Release Notes:

- Fixed an issue where nested opacity is rendered incorrectly.

Change summary

crates/gpui/examples/opacity.rs | 56 ++++++++++++++++------------------
crates/gpui/src/window.rs       | 19 ++++++-----
2 files changed, 38 insertions(+), 37 deletions(-)

Detailed changes

crates/gpui/examples/opacity.rs 🔗

@@ -1,10 +1,9 @@
-use std::{fs, path::PathBuf, time::Duration};
+use std::{fs, path::PathBuf};
 
 use anyhow::Result;
 use gpui::{
     App, Application, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task,
-    Timer, Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size,
-    svg,
+    Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg,
 };
 
 struct Assets {
@@ -37,6 +36,7 @@ impl AssetSource for Assets {
 struct HelloWorld {
     _task: Option<Task<()>>,
     opacity: f32,
+    animating: bool,
 }
 
 impl HelloWorld {
@@ -44,39 +44,29 @@ impl HelloWorld {
         Self {
             _task: None,
             opacity: 0.5,
+            animating: false,
         }
     }
 
-    fn change_opacity(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
+    fn start_animation(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
         self.opacity = 0.0;
+        self.animating = true;
         cx.notify();
-
-        self._task = Some(cx.spawn_in(window, async move |view, cx| {
-            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, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if self.animating {
+            self.opacity += 0.005;
+            if self.opacity >= 1.0 {
+                self.animating = false;
+                self.opacity = 1.0;
+            } else {
+                window.request_animation_frame();
+            }
+        }
+
         div()
             .flex()
             .flex_row()
@@ -96,7 +86,7 @@ impl Render for HelloWorld {
             .child(
                 div()
                     .id("panel")
-                    .on_click(cx.listener(Self::change_opacity))
+                    .on_click(cx.listener(Self::start_animation))
                     .absolute()
                     .top_8()
                     .left_8()
@@ -150,7 +140,15 @@ impl Render for HelloWorld {
                                     .text_2xl()
                                     .size_8(),
                             )
-                            .child("🎊✈️🎉🎈🎁🎂")
+                            .child(
+                                div()
+                                    .flex()
+                                    .children(["🎊", "✈️", "🎉", "🎈", "🎁", "🎂"].map(|emoji| {
+                                        div()
+                                            .child(emoji.to_string())
+                                            .hover(|style| style.opacity(0.5))
+                                    })),
+                            )
                             .child(img("image/black-cat-typing.gif").size_12()),
                     ),
             )

crates/gpui/src/window.rs 🔗

@@ -837,7 +837,7 @@ pub struct Window {
     pub(crate) text_style_stack: Vec<TextStyleRefinement>,
     pub(crate) rendered_entity_stack: Vec<EntityId>,
     pub(crate) element_offset_stack: Vec<Point<Pixels>>,
-    pub(crate) element_opacity: Option<f32>,
+    pub(crate) element_opacity: f32,
     pub(crate) content_mask_stack: Vec<ContentMask<Pixels>>,
     pub(crate) requested_autoscroll: Option<Bounds<Pixels>>,
     pub(crate) image_cache_stack: Vec<AnyImageCache>,
@@ -1222,7 +1222,7 @@ impl Window {
             rendered_entity_stack: Vec::new(),
             element_offset_stack: Vec::new(),
             content_mask_stack: Vec::new(),
-            element_opacity: None,
+            element_opacity: 1.0,
             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())),
@@ -2435,14 +2435,16 @@ impl Window {
         opacity: Option<f32>,
         f: impl FnOnce(&mut Self) -> R,
     ) -> R {
-        if opacity.is_none() {
+        self.invalidator.debug_assert_paint_or_prepaint();
+
+        let Some(opacity) = opacity else {
             return f(self);
-        }
+        };
 
-        self.invalidator.debug_assert_paint_or_prepaint();
-        self.element_opacity = opacity;
+        let previous_opacity = self.element_opacity;
+        self.element_opacity = previous_opacity * opacity;
         let result = f(self);
-        self.element_opacity = None;
+        self.element_opacity = previous_opacity;
         result
     }
 
@@ -2539,9 +2541,10 @@ impl Window {
 
     /// Obtain the current element opacity. This method should only be called during the
     /// prepaint phase of element drawing.
+    #[inline]
     pub(crate) fn element_opacity(&self) -> f32 {
         self.invalidator.debug_assert_paint_or_prepaint();
-        self.element_opacity.unwrap_or(1.0)
+        self.element_opacity
     }
 
     /// Obtain the current content mask. This method should only be called during element drawing.