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

commite0bcd1eb43cfac82df22834435d8b874313994b9
parenta2ab147c2e
authorLucas Galante <[email protected]>
date2026-08-06 08:40
feat: the dropdown expands its ACTUAL button surface into the menu

The unified open surface is now the real trigger relief — the flush
inset plate (groove ring + beveled lip) grown over the trigger band and
revealed menu — instead of a flat popover-theme box. The configured
dropdown fill is usually transparent (the window plate is the visible
face), so the expansion substitutes the opaque plate color: the menu
must cover the content beneath it. Rows render in the standard label
palette over the plate face.

Plumbing: RenderTarget gains inset_plate (degrading to a flat rounded
fill for collector hosts), and PaintCtx now implements RenderTarget —
display-list apps pass their frame ctx straight into render_popover and
popovers draw real prims with their own per-label bounds.

The animation progress geometry readers use is now a per-frame SNAPSHOT
(refreshed in tick before each render, and on every routed event)
instead of a live wall-clock read: the occlusion clamp compares text
bounds against popover_rect re-evaluated later in the same pass with a
1px tolerance, so a moving rect read at two instants silently killed
every popover label mid-animation.

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

 src/layout.rs                |  7 +++++
 src/scene/paint.rs           | 52 +++++++++++++++++++++++++++++++++
 src/widget/input/dropdown.rs | 68 ++++++++++++++++++++++++++++++++++----------
 3 files changed, 112 insertions(+), 15 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 55171ba..3a50775 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3464,6 +3464,13 @@ pub trait RenderTarget {
     }
     fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
     fn pop_clip_rect(&mut self) {}
+    /// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
+    /// control surface (groove ring down, beveled lip back up). Lets a popover
+    /// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
+    /// Hosts without relief prims degrade to a flat rounded fill.
+    fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
+        self.rect_with_radius(color, x, y, w, h, radius);
+    }
     /// Whether this host renders sections as sunken wells (the designer idiom).
     /// `SectionContext` then lays the title out left-aligned over its tab box
     /// instead of centered on the top border.
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 6b9253f..b7867f5 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -662,6 +662,58 @@ impl PaintCtx {
     }
 }
 
+/// `PaintCtx` as a popover render target: display-list hosts pass their frame
+/// ctx straight into `render_popover`, so popovers draw REAL prims — relief
+/// plates, rounded rects, bounded text — instead of the flattened
+/// `PopoverCollector` view (which stays for legacy tuple hosts).
+impl crate::layout::RenderTarget for PaintCtx {
+    fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
+        self.quad(Rect { x, y, width: w, height: h }, color);
+    }
+    fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32) {
+        self.rounded_rect(Rect { x, y, width: w, height: h }, radius, (true, true, true, true), color);
+    }
+    fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, corners: (bool, bool, bool, bool)) {
+        self.rounded_rect(Rect { x, y, width: w, height: h }, radius, corners, color);
+    }
+    fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
+        let c = [
+            (color[0] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[1] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[2] * 255.0).clamp(0.0, 255.0) as u8,
+        ];
+        PaintCtx::text(self, content, x, y, size, c);
+    }
+    fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
+        crate::layout::RenderTarget::text_with_font_and_bounds(self, content, x, y, size, color, font, None);
+    }
+    fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
+        let c = [
+            (color[0] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[1] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[2] * 255.0).clamp(0.0, 255.0) as u8,
+        ];
+        self.text_with(content, x, y, size, c, None, bounds);
+    }
+    fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
+        let c = [
+            (color[0] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[1] * 255.0).clamp(0.0, 255.0) as u8,
+            (color[2] * 255.0).clamp(0.0, 255.0) as u8,
+        ];
+        self.text_with(content, x, y, size, c, Some(font.to_string()), bounds);
+    }
+    fn push_clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.push_clip(Rect { x, y, width: w, height: h });
+    }
+    fn pop_clip_rect(&mut self) {
+        self.pop_clip();
+    }
+    fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32) {
+        PaintCtx::inset_plate(self, Rect { x, y, width: w, height: h }, (radius, radius, radius, radius), color, depth);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index b91c498..89b8593 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -122,6 +122,14 @@ pub struct Dropdown {
     anim_from: f32,
     anim_start: Option<std::time::Instant>,
     closing: bool,
+    /// Per-frame SNAPSHOT of the wall-clock progress, refreshed in `tick`
+    /// (before each render) and on every routed event. All geometry readers —
+    /// popover_rect at registration, draw_popover, the engine's occlusion
+    /// clamp — use this one value, so the animated rect is stable within a
+    /// frame: the clamp's exact-match overlay-text exemption compares text
+    /// bounds against popover_rect evaluated later in the same pass, and a
+    /// live clock read there would never match.
+    anim_snap: f32,
 }
 
 impl Dropdown {
@@ -144,6 +152,7 @@ impl Dropdown {
             anim_from: 0.0,
             anim_start: None,
             closing: false,
+            anim_snap: 0.0,
         })
     }
 
@@ -217,9 +226,10 @@ impl Dropdown {
     /// Expansion/contraction duration — the status-interface module-menu pace.
     const ANIM_S: f32 = 0.14;
 
-    /// Current animation progress in [0, 1], wall-clock from the last
-    /// transition. 1 = fully open, 0 = fully contracted.
-    fn anim_progress(&self) -> f32 {
+    /// LIVE animation progress in [0, 1], wall-clock from the last transition.
+    /// 1 = fully open, 0 = fully contracted. Geometry never reads this
+    /// directly — it reads the per-frame `anim_snap` (see the field docs).
+    fn anim_progress_now(&self) -> f32 {
         let Some(start) = self.anim_start else {
             return if self.open && !self.closing { 1.0 } else { 0.0 };
         };
@@ -232,40 +242,44 @@ impl Dropdown {
     }
 
     fn begin_open(&mut self) {
-        self.anim_from = self.anim_progress();
+        self.anim_from = self.anim_progress_now();
         self.anim_start = Some(std::time::Instant::now());
         self.open = true;
         self.closing = false;
+        self.anim_snap = self.anim_from;
     }
 
     fn begin_close(&mut self) {
         if !self.open || self.closing {
             return;
         }
-        self.anim_from = self.anim_progress();
+        self.anim_from = self.anim_progress_now();
         self.anim_start = Some(std::time::Instant::now());
         self.closing = true;
+        self.anim_snap = self.anim_from;
     }
 
-    /// Fold finished animations back into settled state (draw paths are `&self`,
-    /// so this runs from the mutation entry points: `tick` and `on_event`). Also
-    /// heals an externally forced `open = false` (a direct field write skips the
+    /// Fold finished animations back into settled state and refresh the
+    /// per-frame progress snapshot (draw paths are `&self`, so this runs from
+    /// the mutation entry points: `tick` and `on_event`). Also heals an
+    /// externally forced `open = false` (a direct field write skips the
     /// animation; reset so the next open still animates).
     fn settle_anim(&mut self) {
         if self.closing {
-            if self.anim_progress() <= 0.0 {
+            if self.anim_progress_now() <= 0.0 {
                 self.closing = false;
                 self.open = false;
                 self.anim_start = None;
                 self.hovered_item = None;
             }
         } else if self.open {
-            if self.anim_progress() >= 1.0 {
+            if self.anim_progress_now() >= 1.0 {
                 self.anim_start = None;
             }
         } else {
             self.anim_start = None;
         }
+        self.anim_snap = self.anim_progress_now();
     }
 
     /// Land an in-flight open or close instantly (tests can't wait out the
@@ -303,7 +317,7 @@ impl Dropdown {
     /// upward popover anchors its bottom edge to the trigger instead.
     fn popover_geom_drawn(&self, content: Rect) -> (f32, f32, f32, f32) {
         let (rx, ry, rw, rh) = self.popover_geom(content);
-        let a = self.anim_progress();
+        let a = self.anim_snap;
         if a >= 1.0 {
             return (rx, ry, rw, rh);
         }
@@ -951,9 +965,30 @@ impl Paint for Dropdown {
         let theme = colors::active_theme();
         let (ux, uy, uw, uh) = self.unified_geom_drawn(rect);
 
-        // The unified box: border + fill, trigger band through menu bottom.
-        pc.rect(theme.surface_border, ux, uy, uw, uh);
-        pc.rect(theme.surface_bg, ux + 1.0, uy + 1.0, uw - 2.0, uh - 2.0);
+        // The ACTUAL button surface, expanded: the raised trigger's flush
+        // 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.
+        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;
+            c
+        };
+        if self.raised {
+            let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+            pc.inset_plate(face, ux, uy, uw, uh, radius, depth);
+        } else {
+            pc.rect_with_radius(self.border_color(), ux, uy, uw, uh, radius);
+            pc.rect_with_radius(face, ux + 1.0, uy + 1.0, uw - 2.0, uh - 2.0, (radius - 1.0).max(0.0));
+        }
 
         // Trigger content redrawn over its band (the box covers the widget-pass
         // trigger paint) — display text left, ▼ right, the paint_text palette.
@@ -1017,7 +1052,10 @@ impl Paint for Dropdown {
                 1.0,
             ];
 
-            let bounds = Some([ax, ay, ax + aw, ay + ah]);
+            // Bounds = the unified popover rect EXACTLY (not the menu sub-box):
+            // the dl-text occlusion clamp exempts only exact-match overlay
+            // labels, and the unified box's traveling edge clips identically.
+            let bounds = Some([ux, uy, ux + uw, uy + uh]);
             let font = crate::layout::control_label_font_detached();
             pc.text_with_font_and_bounds(opt, rx + 8.0, iy, 12.0, color_f32, &font, bounds);
         }