GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
}