git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit063177b1e9b69172049ab0dfd1e639a99d4d2a05
parent07cfce4554
authorLucas Galante <[email protected]>
date2026-08-30 20:47
feat: frosted menus — dropdowns and menubar popovers blur what they cover

The Dropdown popover forced its face opaque ("the menu must cover the
content beneath it") and menu.rs's panels painted solid surface_bg — both
predating the blur-behind frost the context menu already wears. Menus now
share one material: the negative-alpha sentinel, so an open menu shows the
app's content beneath it blurred and tinted instead of covered. The
renderer already does the work — at the first frosted plate the UI pass
snapshots the frame-so-far and blur plates sample it.

The tint magnitude is a new style knob rather than the plate color's own
alpha: page colors are typically fully opaque, which resolved the frost
mix to a solid tint — the context menu has been "frosted" invisibly for
exactly that reason. `/style/surface/menu/opacity` (default 0.8) now sets
the strength for all three popover flavors — Dropdown, menu.rs panels,
and ContextMenuState::paint — and 1.0 restores full opacity.

inset_plate's fill gate reads |alpha| now: a frosted face is a real face,
and the positive-only test silently dropped it.

Measured in cce-files with the view dropdown open over the selected row:
the popover face shifts [~0, +6, +11] where the blue row passes beneath —
about 20% of the raw content delta, consistent with 0.8 tint over the
blurred snapshot.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/color.rs                 | 14 ++++++++++++++
 src/scene/paint.rs           |  4 +++-
 src/widget/container/menu.rs |  7 ++++++-
 src/widget/core.rs           |  4 +++-
 src/widget/input/dropdown.rs | 21 ++++++++++++---------
 5 files changed, 38 insertions(+), 12 deletions(-)

diff --git a/src/color.rs b/src/color.rs
index e15d8eb..e6b8e16 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -63,6 +63,9 @@ static TEXTBOX_BACKGROUND_EDIT_COLOR: RwLock<[f32; 4]> = RwLock::new([0.06, 0.10
 static BACKPLATE_MENUBAR_COLOR: RwLock<[f32; 4]> = RwLock::new([0.08, 0.08, 0.12, 1.0]);
 static BACKPLATE_MENUBAR_TEXT_COLOR: RwLock<[f32; 4]> = RwLock::new([0.90196, 0.90196, 0.94902, 1.0]);
 static BACKPLATE_MENUBAR_BLUR: RwLock<bool> = RwLock::new(false);
+/// Tint strength of frosted menus/popovers over the blurred backdrop:
+/// 1.0 is fully opaque (frost invisible), lower shows more content through.
+static MENU_OPACITY: RwLock<f32> = RwLock::new(0.8);
 
 static BACKPLATE_STATUSBAR_COLOR: RwLock<[f32; 4]> = RwLock::new([0.06, 0.06, 0.10, 1.0]);
 static BACKPLATE_STATUSBAR_TEXT_COLOR: RwLock<[f32; 4]> = RwLock::new([0.6666, 0.6666, 0.7333, 1.0]);
@@ -312,6 +315,10 @@ fn parse_and_set_colors(content: &str) {
     {
         if let Ok(mut lock) = BACKPLATE_MENUBAR_TEXT_COLOR.write() { *lock = c; }
     }
+    if let Some(o) = val.pointer("/style/surface/menu/opacity").and_then(|v| v.as_f64()) {
+        if let Ok(mut lock) = MENU_OPACITY.write() { *lock = (o as f32).clamp(0.0, 1.0); }
+    }
+
     let menubar_blur_ptr = val.pointer("/style/surface/plate/root/menubar/blur")
         .or_else(|| val.pointer("/style/surface/backplate/menubar/blur"));
     if let Some(blur) = menubar_blur_ptr.and_then(|v| v.as_bool()) {
@@ -1161,6 +1168,13 @@ pub fn read_opacity_if_configured() -> Option<f32> {
     *OPACITY.read().unwrap()
 }
 
+/// Tint strength for frosted menus/popovers (`/style/surface/menu/opacity`,
+/// default 0.8): the |alpha| of the blur-behind sentinel their plates carry.
+pub fn menu_opacity() -> f32 {
+    load_colors_once();
+    *MENU_OPACITY.read().unwrap()
+}
+
 pub fn read_backplate_opacity_if_configured() -> Option<f32> {
     load_colors_once();
     *BACKPLATE_OPACITY.read().unwrap()
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 866dfc7..97c0ebf 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -956,7 +956,9 @@ impl PaintCtx {
     /// An opaque `color` fills the face; transparent leaves the surface below
     /// showing through as the face.
     pub fn inset_plate(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32) {
-        if color[3] > 0.001 {
+        // abs(): negative alpha is the blur-behind frost sentinel, a real
+        // face — only a genuinely transparent color skips the fill.
+        if color[3].abs() > 0.001 {
             // Flat fill only — the relief is the trough's, so the face must not
             // carry a lip of its own (that lip WAS the second wall).
             //
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 0818949..59435bf 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -694,7 +694,12 @@ impl Paint for MenuBar {
             pc.rect([0.02, 0.02, 0.05, 0.08], dx + 3.0, dy + 3.0, dw, dh);
             pc.rect([0.02, 0.02, 0.05, 0.04], dx + 5.0, dy + 5.0, dw, dh);
             pc.rect(theme.surface_border, dx, dy, dw, dh);
-            pc.rect(theme.surface_bg, dx + 1.0, dy + 1.0, dw - 2.0, dh - 2.0);
+            // Frosted, like the Dropdown popover and the context menu (the
+            // negative-alpha blur-behind sentinel): menus show what is
+            // beneath them blurred and tinted, not covered.
+            let mut bg = theme.surface_bg;
+            bg[3] = -crate::color::menu_opacity();
+            pc.rect(bg, dx + 1.0, dy + 1.0, dw - 2.0, dh - 2.0);
             if let Some(di) = hovered {
                 let iy = dy + di as f32 * DROPDOWN_ITEM_H;
                 pc.rect(theme.primary_accent, dx + 2.0, iy + 2.0, dw - 4.0, DROPDOWN_ITEM_H - 4.0);
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 305af91..4fa37a5 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -495,7 +495,9 @@ pub mod context_menu {
             let face = crate::color::page_low_color();
             if face[3] > 0.001 {
                 let mut frosted = face;
-                frosted[3] = -frosted[3];
+                // menu_opacity, not the color's own alpha: an opaque page
+                // color resolved the frost to a solid tint (invisible).
+                frosted[3] = -crate::color::menu_opacity();
                 ctx.plate(rect, (r, r, r, r), frosted, depth);
             } else {
                 ctx.boss(rect, (r, r, r, r), depth);
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 1369df4..5fda2f0 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -979,17 +979,20 @@ impl Paint for Dropdown {
         // inset plate grown over the unified box (real relief prims on a
         // PaintCtx-backed target; collector hosts degrade to a rounded fill).
         // The trigger's configured fill is usually transparent — the window
-        // plate IS its face — so the expansion substitutes the opaque plate
-        // color: the menu must cover the content beneath it.
+        // plate IS its face — so the expansion substitutes the plate color,
+        // FROSTED: the negative-alpha blur-behind sentinel, the same
+        // material the context menu wears (`ContextMenuState::paint`), so
+        // the menu shows the content beneath it blurred and tinted rather
+        // than covering it. The magnitude is the color's own alpha — an
+        // app styled fully opaque stays opaque, matching its windows.
         let radius = crate::layout::dropdown_corner_radius();
         let raw_bg = colors::dropdown_background_color();
-        let face = if raw_bg[3] > 0.001 {
-            let mut c = raw_bg;
-            c[3] = 1.0;
-            c
-        } else {
-            let mut c = crate::color::page_low_color();
-            c[3] = 1.0;
+        let face = {
+            let mut c = if raw_bg[3] > 0.001 { raw_bg } else { crate::color::page_low_color() };
+            // Magnitude from menu_opacity, not the color's own alpha: page
+            // colors are typically fully opaque, which would resolve the
+            // frost to a solid tint and hide it entirely.
+            c[3] = -crate::color::menu_opacity();
             c
         };
         if self.raised {