GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: shade bevels as surfaces — gradient roll-off, rims vs steps, Plate
The lip was one flat color per edge plus 16 flat wedges per corner, which
reads as a painted stripe rather than a curved surface. It is now sliced into
Gouraud-interpolated bands (the WGSL shader smooth-interpolates vertex color,
so this costs geometry only) following the sine of the remaining tilt — the
curve of a quarter-round rather than a linear 45° chamfer. Corners emit one
quad per (sweep segment × band) with all four vertices shaded independently,
so the shading varies along the sweep and across the lip at once and the
faceting is gone.
Only RGB varies; alpha is held equal across every vertex, because a negative
alpha is the blur-behind sentinel and a gradient crossing zero would flip part
of a triangle into blur mode mid-primitive.
Edges now come in two kinds, because they are different shapes:
Rim — the surface ends (a plate's perimeter). Quarter-round peaking at the
boundary and dying inward.
Step — the surface continues at another height (a sunken menubar). A height
field falling monotonically across the transition keeps its normal
tilted toward the low side throughout, so the shading is a symmetric
bump straddling the boundary. Hanging the band on one side, as a rim
profile does, leaves a hard seam that reads as a drawn line.
New `Prim::Plate` (fill + rolled perimeter at full size, unlike `Bevel` which
insets its fill) and `Prim::Recess` gains a per-wall mask: a full-width bar
flush with the plate edge is a plateau one step down, not a trough, so only
the wall facing the content is real — the other three sides are the plate's
own rim.
StatusBar gains with_recess, mirroring MenuBar to the bottom edge. Roll-off
width is the new `window_manager.bevel_width` (default 14 logical px),
companion to the existing bevel_depth amplitude, capped per widget so a deep
setting cannot swallow a short bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
src/backend/window_runner.rs | 268 +++++++++++++++++++++++++++++++++------
src/layout.rs | 10 ++
src/scene/paint.rs | 29 ++++-
src/widget/container/menu.rs | 29 +++--
src/widget/display/status_bar.rs | 36 +++++-
src/widget/model.rs | 3 +-
6 files changed, 318 insertions(+), 57 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 5123568..41bf4a5 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -381,6 +381,30 @@ pub fn quad_vertices_with_clip(
]
}
+/// A quad whose four corners each carry their own color, Gouraud-interpolated across both
+/// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
+/// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
+/// so a gradient that crossed zero would tear the triangle in half.
+pub fn quad_vertices_shaded(
+ x: f32, y: f32, w: f32, h: f32,
+ sw: f32, sh: f32,
+ c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
+ clip_circle: [f32; 3],
+) -> [Vertex; 6] {
+ let x0 = (x / sw) * 2.0 - 1.0;
+ let y0 = 1.0 - (y / sh) * 2.0;
+ let x1 = ((x + w) / sw) * 2.0 - 1.0;
+ let y1 = 1.0 - ((y + h) / sh) * 2.0;
+ [
+ Vertex { position: [x0, y0], color: c_tl, clip_circle },
+ Vertex { position: [x1, y0], color: c_tr, clip_circle },
+ Vertex { position: [x0, y1], color: c_bl, clip_circle },
+ Vertex { position: [x1, y0], color: c_tr, clip_circle },
+ Vertex { position: [x1, y1], color: c_br, clip_circle },
+ Vertex { position: [x0, y1], color: c_bl, clip_circle },
+ ]
+}
+
pub fn quad_vertices_clipped(
x: f32, y: f32, w: f32, h: f32,
surface_w: f32, surface_h: f32,
@@ -862,6 +886,90 @@ pub fn push_bevel_edge_vertices_radii(
clip_circle: [f32; 3],
light_sign: f32,
out: &mut Vec<Vertex>,
+) {
+ push_bevel_edge_vertices_banded(
+ x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
+ default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
+ );
+}
+
+/// What kind of height change an edge represents. The two shade differently because they
+/// are different shapes, and using one where the other belongs is what makes a bevel read
+/// as a drawn line instead of a surface.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EdgeKind {
+ /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
+ /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
+ /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
+ /// perimeter.
+ Rim,
+ /// The surface *continues* at a different height: one plateau steps down to another.
+ /// A height field that falls monotonically across the transition has its normal tilted
+ /// toward the low side the whole way, steepest in the middle and flat at both ends —
+ /// so the shading is a bump straddling the boundary, not a band butted against it.
+ /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
+ Step,
+}
+
+/// Shading across an edge at signed distance `d` from the boundary (positive = toward the
+/// shape's interior), for a transition of width `t`. Returns the light term as a fraction
+/// of full tilt.
+#[inline]
+fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
+ if t <= 0.0 {
+ return 0.0;
+ }
+ match kind {
+ // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
+ // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
+ EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
+ // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
+ // both plateaus with no seam.
+ EdgeKind::Step => {
+ let s = (d / t + 0.5).clamp(0.0, 1.0);
+ (s * std::f32::consts::PI).sin()
+ }
+ }
+}
+
+/// The signed distance range an edge's shading occupies, relative to the boundary.
+#[inline]
+fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
+ match kind {
+ EdgeKind::Rim => (0.0, t),
+ EdgeKind::Step => (-0.5 * t, 0.5 * t),
+ }
+}
+
+/// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
+/// linearly, so each band is a chord of [`bevel_profile`]'s curve; one band per ~1.5px
+/// keeps the error under a shade step without emitting geometry finer than the display
+/// resolves. A 2px lip stays a single ramp; a 12px rolled edge gets eight.
+fn default_bevel_bands(t: f32) -> usize {
+ ((t / 1.5).ceil() as usize).clamp(1, 8)
+}
+
+/// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
+/// selectable — for callers that want a coarser or finer roll-off than thickness alone
+/// implies, or that are shading a step rather than a closed shape.
+///
+/// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
+/// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
+/// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
+/// content. Drawing the other three would carve a lip along the plate's outer edge, where
+/// the plate's own roll already lives, and the two would fight.
+pub fn push_bevel_edge_vertices_banded(
+ x: f32, y: f32, ww: f32, h: f32,
+ radii: (f32, f32, f32, f32),
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+ light_sign: f32,
+ bands: usize,
+ edges: (bool, bool, bool, bool),
+ kind: EdgeKind,
+ out: &mut Vec<Vertex>,
) {
let cap = ww.min(h) * 0.5;
let (tl, tr, br, bl) = (
@@ -870,14 +978,22 @@ pub fn push_bevel_edge_vertices_radii(
radii.2.clamp(0.0, cap),
radii.3.clamp(0.0, cap),
);
+ let t = t.clamp(0.0, cap);
+ if t <= 0.0 {
+ return;
+ }
+ let bands = bands.max(1);
let rad = crate::layout::light_source_position();
let lx = rad.cos() * light_sign;
let ly = -rad.sin() * light_sign;
+ let depth = crate::layout::bevel_depth();
- let edge_color = |factor: f32| -> [f32; 4] {
- let max_offset = crate::layout::bevel_depth();
- let offset = factor * max_offset;
+ // Only RGB moves; alpha is held at the base value on every vertex. The renderer reads
+ // a negative alpha as the blur-behind sentinel (shader2d.wgsl), so interpolating alpha
+ // could cross zero and flip part of a triangle into blur mode.
+ let shade = |factor: f32| -> [f32; 4] {
+ let offset = factor.clamp(-1.0, 1.0) * depth;
[
(base_color[0] + offset).clamp(0.0, 1.0),
(base_color[1] + offset).clamp(0.0, 1.0),
@@ -885,46 +1001,106 @@ pub fn push_bevel_edge_vertices_radii(
base_color[3],
]
};
-
- let top_color = edge_color(-ly);
- let left_color = edge_color(-lx);
- let bottom_color = edge_color(ly);
- let right_color = edge_color(lx);
+ // Color at signed distance `d` from the boundary, for an edge whose outward normal is
+ // `dir`. A `Step` band runs negative — it straddles the boundary into the plateau
+ // outside the rect, which is exactly what removes the seam.
+ let band = |dir: (f32, f32), d: f32| shade(bevel_profile(kind, d, t) * (dir.0 * lx + dir.1 * ly));
+ let (span_lo, span_hi) = bevel_span(kind, t);
// Each flat edge spans between its two adjoining corner radii, not a single uniform
- // inset — that is what lets the corners differ.
- out.extend_from_slice(&quad_vertices_with_clip(x + tl, y, ww - tl - tr, t, sw, sh, top_color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x, y + tl, t, h - tl - bl, sw, sh, left_color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + bl, y + h - t, ww - bl - br, t, sw, sh, bottom_color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + tr, t, h - tr - br, sw, sh, right_color, clip_circle));
+ // inset — that is what lets the corners differ. At a square corner there is no arc to
+ // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
+ // the full span) and the vertical ones inset by `t`; overlapping them instead would
+ // double-blend that patch, which shows as a dark notch on a translucent surface.
+ let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
+ let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
+ let top_w = ww - tl - tr;
+ let bottom_w = ww - bl - br;
+ let left_h = h - left_top - left_bot;
+ let right_h = h - right_top - right_bot;
+
+ for k in 0..bands {
+ let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
+ let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
+ let bw = d1 - d0;
+
+ // Top: outward normal (0,-1); the gradient runs downward, into the surface.
+ if top_w > 0.0 && edges.0 {
+ let (c0, c1) = (band((0.0, -1.0), d0), band((0.0, -1.0), d1));
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
+ ));
+ }
+ // Bottom: outward normal (0,1); gradient runs upward.
+ if bottom_w > 0.0 && edges.2 {
+ let (c0, c1) = (band((0.0, 1.0), d0), band((0.0, 1.0), d1));
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
+ ));
+ }
+ // Left: outward normal (-1,0); gradient runs rightward.
+ if left_h > 0.0 && edges.3 {
+ let (c0, c1) = (band((-1.0, 0.0), d0), band((-1.0, 0.0), d1));
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
+ ));
+ }
+ // Right: outward normal (1,0); gradient runs leftward.
+ if right_h > 0.0 && edges.1 {
+ let (c0, c1) = (band((1.0, 0.0), d0), band((1.0, 0.0), d1));
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
+ ));
+ }
+ }
- let segments = 16;
+ // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
+ // are — otherwise a suppressed wall would still get a quarter of a lip.
let corners = [
- (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI), // Top-Left
- (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI), // Top-Right
- (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI), // Bottom-Right
- (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI), // Bottom-Left
+ (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
+ (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
+ (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
+ (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
];
- for &(cx, cy, r, start_angle, end_angle) in &corners {
- // A square corner has no arc to sweep — the two flat edges already meet there.
- if r <= 0.0 {
+ for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
+ // A square corner has no arc to sweep — the flat edges already met there.
+ if r <= 0.0 || !enabled {
continue;
}
+ // The corner is a quarter of a torus: shading varies along the sweep (the normal
+ // swings through 90° of the light) *and* across the lip (the roll-off). Both come
+ // out of the vertex colors, so one quad per (segment × band) cell is enough — no
+ // faceting, unlike the 16 flat wedges this replaced.
+ let segments = ((r * 0.75) as usize).clamp(8, 48);
+ let ct = t.min(r);
for j in 0..segments {
- let theta1 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
- let theta2 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let theta_mid = 0.5 * (theta1 + theta2);
-
- let factor = (theta_mid.cos() * lx + theta_mid.sin() * ly).clamp(-1.0, 1.0);
- let segment_color = edge_color(factor);
-
- push_arc_background_vertices(
- cx, cy, r, t,
- theta1, theta2,
- sw, sh, segment_color, 1, clip_circle,
- out,
- );
+ let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
+ let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
+ let (cos0, sin0) = (theta0.cos(), theta0.sin());
+ let (cos1, sin1) = (theta1.cos(), theta1.sin());
+ let f0 = cos0 * lx + sin0 * ly;
+ let f1 = cos1 * lx + sin1 * ly;
+ for k in 0..bands {
+ let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
+ let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
+ // Inward along the corner's radius is the same signed distance as inward
+ // from a flat edge, so the arc scales the span the same way.
+ let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
+ let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
+ [
+ ((cx + rho * c) / sw) * 2.0 - 1.0,
+ 1.0 - ((cy + rho * s) / sh) * 2.0,
+ ]
+ };
+ // Outer/inner × the two sweep ends; each vertex gets its own shade.
+ let (p0, p1) = (bevel_profile(kind, d0, t), bevel_profile(kind, d1, t));
+ let v00 = Vertex { position: p(r0, cos0, sin0), color: shade(p0 * f0), clip_circle };
+ let v10 = Vertex { position: p(r0, cos1, sin1), color: shade(p0 * f1), clip_circle };
+ let v11 = Vertex { position: p(r1, cos1, sin1), color: shade(p1 * f1), clip_circle };
+ let v01 = Vertex { position: p(r1, cos0, sin0), color: shade(p1 * f0), clip_circle };
+ out.extend_from_slice(&[v00, v10, v11, v00, v11, v01]);
+ }
}
}
}
@@ -1144,13 +1320,31 @@ pub fn tessellate_display_list(
push_rounded_rect_vertices_corners(rect.x + t, rect.y + t, rect.width - 2.0 * t, rect.height - 2.0 * t, inner, sw, sh, *color, no, None, &mut verts);
push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, t, sw, sh, *color, no, &mut verts);
}
- Prim::Recess { rect, radii, surface, depth } => {
+ Prim::Plate { rect, radii, color, depth } => {
+ // Fill at full size (no inset — see Prim::Plate), then roll the perimeter.
+ // The lip rides on top of the fill's outer band rather than replacing it,
+ // so the plate's silhouette and the compositor's rounded window corners
+ // still agree exactly.
+ let corners = crate::widget::CornerRadii {
+ top_left: radii.0, top_right: radii.1,
+ bottom_right: radii.2, bottom_left: radii.3,
+ };
+ push_rounded_rect_vertices_corners(
+ rect.x, rect.y, rect.width, rect.height, corners, sw, sh, *color, no, None, &mut verts,
+ );
+ push_bevel_edge_vertices_radii(
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
+ sw, sh, *color, no, 1.0, &mut verts,
+ );
+ }
+ Prim::Recess { rect, radii, surface, depth, edges } => {
// Edges only — no fill, so the surface already painted below shows through
// the middle of the carve. `light_sign = -1.0` shadows the lit-facing edges,
// which is the raised->recessed inversion.
- push_bevel_edge_vertices_radii(
+ push_bevel_edge_vertices_banded(
rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, *surface, no, -1.0, &mut verts,
+ sw, sh, *surface, no, -1.0, default_bevel_bands(*depth), *edges,
+ EdgeKind::Step, &mut verts,
);
}
Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
diff --git a/src/layout.rs b/src/layout.rs
index 09b842b..cc6bdc0 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -108,6 +108,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
"style.control.toggle.corner_radius" => "toggle_corner_radius",
"window_manager.light_source_position" => "light_source_position",
"window_manager.bevel_depth" => "bevel_depth",
+ "window_manager.bevel_width" => "bevel_width",
"style.control.ramp.height" => "ramp_height",
"style.layout.column.gap" => "column_gap",
"style.control.control_panel.padding" => "control_panel_padding",
@@ -1487,6 +1488,15 @@ pub fn bevel_depth() -> f32 {
get_style_registry().read().unwrap().get_float("bevel_depth").unwrap_or(0.15)
}
+/// How wide a rolled edge is, in logical px — the distance over which a plate's perimeter
+/// or a recess wall curves away from the flat surface. `bevel_depth` is the companion
+/// knob: it sets how hard the light falls across that distance. Wide and shallow reads as
+/// thick glass; narrow and deep reads as a stamped metal lip.
+pub fn bevel_width() -> f32 {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(14.0)
+}
+
pub fn toggle_corner_radius() -> f32 {
lazy_init_style_registry();
get_style_registry().read().unwrap().get_float("toggle_corner_radius").unwrap_or(4.0)
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index ed7ca28..76d631e 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -53,7 +53,16 @@ pub enum Prim {
/// `surface` is the color of what lies beneath (the thing being carved); it is only
/// the base for the edge shading, and is never filled. Its alpha carries through to
/// the edges, so a recess in a translucent plate stays translucent.
- Recess { rect: Rect, radii: Radii, surface: [f32; 4], depth: f32 },
+ /// `edges` is (top, right, bottom, left): which walls of the carve actually exist.
+ /// A region flush with the plate's own edge is a step, not a trough — see
+ /// `push_bevel_edge_vertices_banded`.
+ Recess { rect: Rect, radii: Radii, surface: [f32; 4], depth: f32, edges: (bool, bool, bool, bool) },
+ /// The window's glass slab: a rounded fill plus a rolled, lit edge around its whole
+ /// perimeter, drawn at full size. Distinct from `Bevel`, which insets its fill by
+ /// `depth` — a plate must fill the window exactly, or the compositor's rounded window
+ /// corners would show a gap. `depth` is the width of the roll-off in px, not a color
+ /// offset (the shading amplitude is the DE-wide `bevel_depth`).
+ Plate { rect: Rect, radii: Radii, color: [f32; 4], depth: f32 },
Arc { cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4] },
Vector { x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, color: [f32; 4], cap: Cap },
Circle { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
@@ -340,8 +349,24 @@ impl PaintCtx {
/// Carve a recess into the already-painted surface below. Unlike `bevel`, this fills
/// nothing — `surface` is the color being carved, used only to shade the edges.
pub fn recess(&mut self, rect: Rect, radii: Radii, surface: [f32; 4], depth: f32) {
+ self.recess_edges(rect, radii, surface, depth, (true, true, true, true));
+ }
+
+ /// [`PaintCtx::recess`] with only some of the walls — see `Prim::Recess`.
+ pub fn recess_edges(
+ &mut self, rect: Rect, radii: Radii, surface: [f32; 4], depth: f32,
+ edges: (bool, bool, bool, bool),
+ ) {
+ let rect = self.apply_offset(rect);
+ self.push(Prim::Recess { rect, radii, surface, depth, edges });
+ }
+
+ /// The window's glass slab: rounded fill at full size plus a rolled, lit perimeter.
+ /// `depth` is the roll-off width in px — pass [`crate::layout::bevel_width`] unless the
+ /// window wants a shallower edge than the DE default.
+ pub fn plate(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32) {
let rect = self.apply_offset(rect);
- self.push(Prim::Recess { rect, radii, surface, depth });
+ self.push(Prim::Plate { rect, radii, color, depth });
}
pub fn arc(&mut self, cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4]) {
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 4925bec..b2ce4fc 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -27,10 +27,6 @@ use crate::widget::{
MenuController, MouseButton, NamedKey, PageSelector, Paint, DROPDOWN_ITEM_H,
};
-/// Thickness in logical px of the shaded lip around a recessed menubar. Two px reads as
-/// a carved edge at a glance without turning into a drawn border.
-const RECESS_EDGE_PX: f32 = 2.0;
-
pub struct MenuBar {
pub visible: bool,
pub network_opacity: f32,
@@ -478,17 +474,22 @@ impl Paint for MenuBar {
if surface[3] > 0.001 {
surface[3] = colors::active_backplate_opacity();
}
- // `depth` is the edge's thickness in px (the color offset is a separate thing:
- // the renderer applies `bevel_depth` itself). The top corners follow the plate's
- // radius so the lip stays inside its arc — square ones there paint a notch out
- // past the rounded plate, into the transparent corner. The bottom corners stay
- // square: that edge meets the content below, not the window edge.
- let plate_r = if rect.x <= 0.5 && rect.y <= 0.5 {
- colors::backplate_corner_radius()
+ // `depth` is the roll-off width in px (the shading amplitude is separate: the
+ // renderer applies `bevel_depth` itself), capped so a deep DE-wide setting can
+ // never swallow a short bar — the two walls would meet in the middle and the
+ // flat floor would vanish.
+ let depth = crate::layout::bevel_width().min(rect.height * 0.4);
+ if rect.x <= 0.5 && rect.y <= 0.5 {
+ // Flush with the plate's top-left: the bar is a plateau one step down, not a
+ // trough, so its only wall is the one facing the content. The other three
+ // sides are the plate's outer edge, where the plate's own roll already lives
+ // — carving there too would cut a second lip into the same pixels.
+ ctx.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), surface, depth, (false, false, true, false));
} else {
- 0.0
- };
- ctx.recess(rect, (plate_r, plate_r, 0.0, 0.0), surface, RECESS_EDGE_PX);
+ // Inset from the plate edge: a real trough, walled all round, its corners
+ // rounded by the roll itself.
+ ctx.recess(rect, (depth, depth, depth, depth), surface, depth);
+ }
} else {
// Background: always the plain quad — the rounded-against-parent variant required a
// backplate parent, which no longer exists.
diff --git a/src/widget/display/status_bar.rs b/src/widget/display/status_bar.rs
index fba1abd..180a1f6 100644
--- a/src/widget/display/status_bar.rs
+++ b/src/widget/display/status_bar.rs
@@ -20,6 +20,11 @@ pub struct StatusBar {
pub text_offset_x: Option<f32>,
pub text_color: Option<[f32; 4]>,
pub bg_color: Option<[f32; 4]>,
+ /// Draw as a step carved into the window backplate instead of an opaque slab: no
+ /// background fill of its own, just the shaded wall facing the content, so the plate
+ /// shows through. `bg_color` is ignored while this is set — see
+ /// [`Adapted::<StatusBar>::with_recess`].
+ pub recessed: bool,
}
impl StatusBar {
@@ -31,6 +36,7 @@ impl StatusBar {
text_offset_x: None,
text_color: None,
bg_color: None,
+ recessed: false,
})
}
@@ -79,6 +85,16 @@ impl Adapted<StatusBar> {
self.bg_color = Some(color);
self
}
+
+ /// Drop the bar's own background and sink it into the window backplate instead, the
+ /// mirror of `MenuBar::with_recess`. A status bar always sits flush with the bottom of
+ /// the plate, so it is shaded as a plateau one step down whose only wall is the top one
+ /// (facing the content) — the other three sides are the plate's outer edge, which
+ /// carries its own roll.
+ pub fn with_recess(mut self, recessed: bool) -> Self {
+ self.recessed = recessed;
+ self
+ }
}
impl Layout for StatusBar {
@@ -121,9 +137,23 @@ impl Paint for StatusBar {
/// default `all_rounded_quads` path) — plus the text label (the legacy `text_labels`
/// body; deliberately no `widget_font`, see module docs).
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
- // Always the plain background quad — the rounded-against-parent variant required a
- // backplate parent, which no longer exists.
- ctx.quad(rect, self.bg());
+ if self.recessed {
+ // The shading base is the plate's own color (page_low at the active backplate
+ // opacity, matching how the window emits it) — these edges are that surface
+ // catching and losing light, so shading a transparent color would just produce
+ // transparent edges.
+ let mut surface = colors::page_low_color();
+ if surface[3] > 0.001 {
+ surface[3] = colors::active_backplate_opacity();
+ }
+ // Capped against the bar's own height so a deep DE-wide roll can't swallow it.
+ let depth = crate::layout::bevel_width().min(rect.height * 0.4);
+ ctx.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), surface, depth, (true, false, false, false));
+ } else {
+ // Always the plain background quad — the rounded-against-parent variant required a
+ // backplate parent, which no longer exists.
+ ctx.quad(rect, self.bg());
+ }
if !self.text.is_empty() {
let offset_x = self.text_offset_x.unwrap_or(12.0);
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 06c18aa..fd894ca 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1235,7 +1235,8 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
Prim::RoundedRect { rect, radius, corners, color } => ctx.rounded_rect(rect, radius, corners, color),
Prim::Border { rect, radii, fill, border, thickness } => ctx.border(rect, radii, fill, border, thickness),
Prim::Bevel { rect, radii, color, depth } => ctx.bevel(rect, radii, color, depth),
- Prim::Recess { rect, radii, surface, depth } => ctx.recess(rect, radii, surface, depth),
+ Prim::Recess { rect, radii, surface, depth, edges } => ctx.recess_edges(rect, radii, surface, depth, edges),
+ Prim::Plate { rect, radii, color, depth } => ctx.plate(rect, radii, color, depth),
Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),