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

commitda6d34547472f29c700695726d438c1e17f574ec
parent4e942368d6
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 11:52
feat: style.surface.menu.color, Material::menu and a shared menu-plate paint

A menu's face was always the root plate colour (page_low_color), so an
app could not darken its menus without darkening its window. The new
`style.surface.menu.color` key names the face on its own; absent, the
root plate colour as before, so no config changes look. Its alpha is
ignored — `menu.opacity` is the tint strength, as it was.

Material::menu is popover(menu_color): THE menu material, for a context
menu and for any surface that should read as one. context_menu::
paint_menu_plate paints that plate over a rect — the material, the menu
corner radius and the relief-width roll, with the popup-hosted opacity
fold when asked — and ContextMenuState::paint now draws its plate
through it, so a host that paints another surface (cce-designer's
command palette) with the same call cannot be configured apart from
the menus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 src/color.rs          | 24 +++++++++++++++++++
 src/scene/material.rs |  9 +++++++
 src/widget/core.rs    | 66 +++++++++++++++++++++++++++++++--------------------
 3 files changed, 73 insertions(+), 26 deletions(-)

diff --git a/src/color.rs b/src/color.rs
index acc16b6..fc253e4 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -119,6 +119,13 @@ static MENU_OPACITY: RwLock<f32> = RwLock::new(0.8);
 /// `plate.backdrop_compression` for popovers only; a menu is read while
 /// something else is going on beneath it, which a pane is not.
 static MENU_COMPRESSION: RwLock<f32> = RwLock::new(0.6);
+/// The colour a menu's face is tinted with (`style.surface.menu.color`),
+/// when the config names one; `None` follows the root plate colour
+/// (`page_low_color`), which is what every menu wore before the key
+/// existed. The alpha is ignored — `menu.opacity` is the tint strength —
+/// so a menu can be dark on a light window without the window's own
+/// plate going dark with it.
+static MENU_COLOR: RwLock<Option<[f32; 4]>> = RwLock::new(None);
 
 static ROOT_PLATE_STATUSBAR_COLOR: RwLock<[f32; 4]> = RwLock::new([0.06, 0.06, 0.10, 1.0]);
 static ROOT_PLATE_STATUSBAR_TEXT_COLOR: RwLock<[f32; 4]> = RwLock::new([0.6666, 0.6666, 0.7333, 1.0]);
@@ -351,6 +358,9 @@ fn parse_and_set_colors(content: &str) {
     if let Some(c) = val.pointer("/style/surface/menu/compression").and_then(|v| v.as_f64()) {
         if let Ok(mut lock) = MENU_COMPRESSION.write() { *lock = (c as f32).clamp(0.0, 1.0); }
     }
+    if let Some(c) = get_color("/style/surface/menu/color") {
+        if let Ok(mut lock) = MENU_COLOR.write() { *lock = Some(c); }
+    }
 
     let menubar_blur_ptr = val.pointer("/style/surface/plate/root/menubar/blur");
     if let Some(blur) = menubar_blur_ptr.and_then(|v| v.as_bool()) {
@@ -1233,6 +1243,20 @@ pub fn set_menu_compression(c: f32) {
     style_write(&MENU_COMPRESSION, c.clamp(0.0, 1.0));
 }
 
+/// The colour a menu's face is tinted with: `style.surface.menu.color` when
+/// configured, else the root plate colour (`page_low_color`) — the face
+/// every menu wore before the key existed, so an unconfigured menu looks as
+/// it always did. Its alpha is not the tint strength; `menu_opacity` is
+/// (`Material::popover` reads both).
+pub fn menu_color() -> [f32; 4] {
+    load_colors_once();
+    style_read(&MENU_COLOR).unwrap_or_else(page_low_color)
+}
+
+pub fn set_menu_color(c: Option<[f32; 4]>) {
+    style_write(&MENU_COLOR, c);
+}
+
 pub fn read_root_plate_opacity_if_configured() -> Option<f32> {
     load_colors_once();
     style_read(&ROOT_PLATE_OPACITY)
diff --git a/src/scene/material.rs b/src/scene/material.rs
index c2370ac..6fe26fb 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -403,6 +403,15 @@ impl Material {
         Self::opaque([base[0], base[1], base[2], crate::color::menu_opacity()]).with_frost(frost)
     }
 
+    /// THE menu material: [`Material::popover`] of `color::menu_color` —
+    /// `style.surface.menu.color`, else the root plate colour. What a
+    /// context menu's plate is made of, and what any other surface that
+    /// should read as one (a command palette, a modal list) asks for, so
+    /// the `style.surface.menu` block is the one place both are configured.
+    pub fn menu() -> Self {
+        Self::popover(crate::color::menu_color())
+    }
+
     // ---- derived materials -------------------------------------------------
 
     /// The well floor cut into this plate: the same material with the tint
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 584a250..86368a4 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -841,32 +841,7 @@ pub mod context_menu {
             };
             let r = crate::layout::menu_corner_radius();
             let depth = crate::layout::bevel_width().min(self.h * 0.2);
-            let face = crate::color::page_low_color();
-            if face[3] > 0.001 {
-                // The popover material: the page colour at menu_opacity,
-                // frosted (an opaque page colour would resolve the frost
-                // to a solid tint, invisible).
-                let material = crate::scene::material::Material::popover(face);
-                let material = if self.in_popup {
-                    // The compositor's blur frosts but cannot COMPRESS: the
-                    // in-app pass pulls the backdrop's luminance a fraction
-                    // `k` toward the plate's key, which is what keeps the
-                    // labels legible over a bright scene. Over glass that
-                    // only blurs, hold the same swing with opacity instead —
-                    // the backdrop reaches the eye at (1 - a)(1 - k) either
-                    // way — or a menu opened over something white washes out.
-                    let k = crate::color::menu_compression().clamp(0.0, 1.0);
-                    let mut m = material.for_role(crate::scene::material::PlateRole::Root);
-                    m.tint[3] = 1.0 - (1.0 - m.tint[3]) * (1.0 - k);
-                    m
-                } else {
-                    material
-                };
-                ctx.plate(rect, (r, r, r, r), &material, depth);
-            } else {
-                let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
-                ctx.boss(plateau, radii, depth);
-            }
+            paint_menu_plate(ctx, rect, self.in_popup);
 
             // What the rows draw — hover, separators, slider bands — is cut at
             // the plate, so a row scrolled half out of a shortened menu stops
@@ -1063,6 +1038,45 @@ pub mod context_menu {
         }
     }
 
+    /// The menu PLATE alone, over `rect`: [`Material::menu`] on a rounded
+    /// face at `style.surface.menu.corner_radius` with the rolled perimeter
+    /// at the relief width (capped at a fifth of the height) — or, for a
+    /// transparent configured face, the edges-only boss. What
+    /// [`ContextMenuState::paint`] draws under its rows, and what any other
+    /// surface that should look like a menu draws under its own (the
+    /// designer's command palette): one function, so the two cannot be
+    /// configured apart.
+    ///
+    /// `in_popup` says the plate is being painted into the runner's popup
+    /// surface, where the compositor's blur frosts but cannot COMPRESS: the
+    /// in-app pass pulls the backdrop's luminance a fraction `k` toward the
+    /// plate's key, which is what keeps the labels legible over a bright
+    /// scene. Over glass that only blurs, the same swing is held with
+    /// opacity instead — the backdrop reaches the eye at (1 - a)(1 - k)
+    /// either way — or a menu opened over something white washes out.
+    ///
+    /// [`Material::menu`]: crate::scene::material::Material::menu
+    pub fn paint_menu_plate(ctx: &mut crate::scene::paint::PaintCtx, rect: crate::scene::layout::Rect, in_popup: bool) {
+        let r = crate::layout::menu_corner_radius();
+        let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+        let face = crate::color::menu_color();
+        if face[3] > 0.001 {
+            let material = crate::scene::material::Material::menu();
+            let material = if in_popup {
+                let k = crate::color::menu_compression().clamp(0.0, 1.0);
+                let mut m = material.for_role(crate::scene::material::PlateRole::Root);
+                m.tint[3] = 1.0 - (1.0 - m.tint[3]) * (1.0 - k);
+                m
+            } else {
+                material
+            };
+            ctx.plate(rect, (r, r, r, r), &material, depth);
+        } else {
+            let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
+            ctx.boss(plateau, radii, depth);
+        }
+    }
+
     thread_local! {
         pub static CONTEXT_MENU: RefCell<ContextMenuState> = RefCell::new(ContextMenuState::new());
     }