GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: per-pixel SDF-lit plate system — CSG carves, squircle corners, control reliefs
Replaces the banded vertex-overlay bevels with a per-pixel lit plate branch
in shader2d (the legacy path stays behind the new bevel_shader config
toggle). A plate is one composite height field — the host's rolled edge
minus surface-relative carves — lit ONCE per pixel from summed analytic
slope vectors, so junctions (a band meeting the plate's perimeter roll) are
arithmetic instead of per-pair fade heuristics, and features never blend in
color space.
- shader2d: analytic rounded-box SDF+gradient (no dpdx), decoupled
profile×azimuth specular (Blinn-Phong crushes edge lines vs corner
glints and blobs the corner meeting), superellipse corners AND roll/step
profiles from one corner_shape exponent (plan-corner span is
curvature-matched for window Plates only), overlay modes for free
recess/boss/ridge with host-roll fade
- carve grouping: Recess prims inside a preceding Plate/Bevel become CSG
features of that plate's draw via a per-frame feature UBO (binding 3,
double-buffered by frame in flight; offsets rebased at record time)
- new prims: Boss (raised plateau, edges only) and Ridge (raised rim in ONE
profile evaluation — a boss+recess stack double-counts specular at the
crest); both degrade to overlays when ungrouped
- widget silhouettes follow corner_shape: superellipse corner fans in the
rounded-rect tessellator and border strokes (arcs stay circular)
- control relief styles (opt-in): Button/Toggle/Dropdown with_raised (lit
Bevel plate replacing flat fill + border; transparent fills degrade to
edges-only Boss), TextBox with_recessed (carved well), Slider with_raised
(ridge around a channel, fill inset into the valley, circular knob sized
to the channel floor)
- demo: lit window Plate, carved menubar/status bands (heights derived from
the chrome fonts), all controls on the relief styles
- config knobs: bevel_shader (A/B), corner_shape (2 = circular, ~4.5 = Apple
continuous curvature)
Co-Authored-By: Claude Fable 5 <[email protected]>
src/backend/window_runner.rs | 377 +++++++++++++++++++++++++++++++++++++------
src/layout.rs | 20 +++
src/main.rs | 96 ++++++++---
src/scene/paint.rs | 54 +++++++
src/vk/mod.rs | 2 +-
src/vk/renderer.rs | 133 +++++++++++++--
src/vk/shader2d.wgsl | 317 +++++++++++++++++++++++++++++++++---
src/widget/input/button.rs | 22 +++
src/widget/input/checkbox.rs | 22 ++-
src/widget/input/dropdown.rs | 28 +++-
src/widget/input/slider.rs | 81 ++++++++--
src/widget/input/text_box.rs | 39 ++++-
src/widget/model.rs | 2 +
13 files changed, 1067 insertions(+), 126 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 8e6bbb8..a2e9bd1 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -562,6 +562,19 @@ pub fn rounded_rect_vertices_corners(
verts
}
+/// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
+/// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
+/// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
+/// corners, so widget silhouettes follow the same corner family as the
+/// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
+/// quadrant ends are unchanged, so fans still tile exactly against the body
+/// rects and edge strips.
+#[inline]
+fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
+ let (s, c) = theta.sin_cos();
+ (c.signum() * c.abs().powf(e), s.signum() * s.abs().powf(e))
+}
+
pub fn push_rounded_rect_vertices_corners(
x: f32, y: f32, ww: f32, h: f32,
radii: crate::widget::CornerRadii,
@@ -571,6 +584,7 @@ pub fn push_rounded_rect_vertices_corners(
clip_rect: Option<(f32, f32, f32, f32)>,
out: &mut Vec<Vertex>,
) {
+ let corner_e = 2.0 / crate::layout::corner_shape();
let mut r_tl = radii.top_left.max(0.0);
let mut r_tr = radii.top_right.max(0.0);
let mut r_br = radii.bottom_right.max(0.0);
@@ -672,12 +686,14 @@ pub fn push_rounded_rect_vertices_corners(
let theta1 = start + (i as f32) * (end - start) / (segments as f32);
let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
+ let (c1, s1) = superellipse_pt(theta1, corner_e);
+ let (c2, s2) = superellipse_pt(theta2, corner_e);
let x0 = clamp_x(cx);
let y0 = clamp_y(cy);
- let x1 = clamp_x(cx + r_tl * theta1.cos());
- let y1 = clamp_y(cy + r_tl * theta1.sin());
- let x2 = clamp_x(cx + r_tl * theta2.cos());
- let y2 = clamp_y(cy + r_tl * theta2.sin());
+ let x1 = clamp_x(cx + r_tl * c1);
+ let y1 = clamp_y(cy + r_tl * s1);
+ let x2 = clamp_x(cx + r_tl * c2);
+ let y2 = clamp_y(cy + r_tl * s2);
let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
@@ -707,12 +723,14 @@ pub fn push_rounded_rect_vertices_corners(
let theta1 = start + (i as f32) * (end - start) / (segments as f32);
let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
+ let (c1, s1) = superellipse_pt(theta1, corner_e);
+ let (c2, s2) = superellipse_pt(theta2, corner_e);
let x0 = clamp_x(cx);
let y0 = clamp_y(cy);
- let x1 = clamp_x(cx + r_tr * theta1.cos());
- let y1 = clamp_y(cy + r_tr * theta1.sin());
- let x2 = clamp_x(cx + r_tr * theta2.cos());
- let y2 = clamp_y(cy + r_tr * theta2.sin());
+ let x1 = clamp_x(cx + r_tr * c1);
+ let y1 = clamp_y(cy + r_tr * s1);
+ let x2 = clamp_x(cx + r_tr * c2);
+ let y2 = clamp_y(cy + r_tr * s2);
let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
@@ -742,12 +760,14 @@ pub fn push_rounded_rect_vertices_corners(
let theta1 = start + (i as f32) * (end - start) / (segments as f32);
let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
+ let (c1, s1) = superellipse_pt(theta1, corner_e);
+ let (c2, s2) = superellipse_pt(theta2, corner_e);
let x0 = clamp_x(cx);
let y0 = clamp_y(cy);
- let x1 = clamp_x(cx + r_br * theta1.cos());
- let y1 = clamp_y(cy + r_br * theta1.sin());
- let x2 = clamp_x(cx + r_br * theta2.cos());
- let y2 = clamp_y(cy + r_br * theta2.sin());
+ let x1 = clamp_x(cx + r_br * c1);
+ let y1 = clamp_y(cy + r_br * s1);
+ let x2 = clamp_x(cx + r_br * c2);
+ let y2 = clamp_y(cy + r_br * s2);
let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
@@ -777,12 +797,14 @@ pub fn push_rounded_rect_vertices_corners(
let theta1 = start + (i as f32) * (end - start) / (segments as f32);
let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
+ let (c1, s1) = superellipse_pt(theta1, corner_e);
+ let (c2, s2) = superellipse_pt(theta2, corner_e);
let x0 = clamp_x(cx);
let y0 = clamp_y(cy);
- let x1 = clamp_x(cx + r_bl * theta1.cos());
- let y1 = clamp_y(cy + r_bl * theta1.sin());
- let x2 = clamp_x(cx + r_bl * theta2.cos());
- let y2 = clamp_y(cy + r_bl * theta2.sin());
+ let x1 = clamp_x(cx + r_bl * c1);
+ let y1 = clamp_y(cy + r_bl * s1);
+ let x2 = clamp_x(cx + r_bl * c2);
+ let y2 = clamp_y(cy + r_bl * s2);
let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
@@ -1306,38 +1328,45 @@ pub fn push_plate_solid_border_vertices(
out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
let segments = 16;
+ let corner_e = 2.0 / crate::layout::corner_shape();
+
+ // Corner strokes as annulus strips between the outer superellipse (radius
+ // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
+ // curve is indistinguishable from the true parallel curve, and at
+ // corner_shape 2 this is exactly the circular arc annulus. NOT
+ // push_arc_background_vertices — that stays circular for genuine arcs.
+ let mut corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
+ let r_in = (r - t).max(0.0);
+ let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
+ for i in 0..segments {
+ let t1 = start + (i as f32) * (end - start) / segments as f32;
+ let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
+ let (c1, s1) = superellipse_pt(t1, corner_e);
+ let (c2, s2) = superellipse_pt(t2, corner_e);
+ let o1 = ndc(cx + r * c1, cy + r * s1);
+ let o2 = ndc(cx + r * c2, cy + r * s2);
+ let i1 = ndc(cx + r_in * c1, cy + r_in * s1);
+ let i2 = ndc(cx + r_in * c2, cy + r_in * s2);
+ out.push(Vertex { position: o1, color, clip_circle });
+ out.push(Vertex { position: o2, color, clip_circle });
+ out.push(Vertex { position: i1, color, clip_circle });
+ out.push(Vertex { position: o2, color, clip_circle });
+ out.push(Vertex { position: i2, color, clip_circle });
+ out.push(Vertex { position: i1, color, clip_circle });
+ }
+ };
if r_tl > 0.1 {
- push_arc_background_vertices(
- x + r_tl, y + r_tl, r_tl, t,
- std::f32::consts::PI, 1.5 * std::f32::consts::PI,
- sw, sh, color, segments, clip_circle,
- out,
- );
+ corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
}
if r_tr > 0.1 {
- push_arc_background_vertices(
- x + ww - r_tr, y + r_tr, r_tr, t,
- 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI,
- sw, sh, color, segments, clip_circle,
- out,
- );
+ corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
}
if r_br > 0.1 {
- push_arc_background_vertices(
- x + ww - r_br, y + h - r_br, r_br, t,
- 0.0, 0.5 * std::f32::consts::PI,
- sw, sh, color, segments, clip_circle,
- out,
- );
+ corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
}
if r_bl > 0.1 {
- push_arc_background_vertices(
- x + r_bl, y + h - r_bl, r_bl, t,
- 0.5 * std::f32::consts::PI, std::f32::consts::PI,
- sw, sh, color, segments, clip_circle,
- out,
- );
+ corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
}
}
@@ -1390,6 +1419,9 @@ pub struct DlBatch {
pub clip_rrect: Option<[f32; 5]>,
pub start: u32,
pub end: u32,
+ /// When set, this batch is one SDF-lit plate cover quad (see
+ /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
+ pub plate: Option<crate::vk::PlatePush>,
}
/// An image draw from the display list: `at` is the vertex index it sorts
@@ -1414,14 +1446,36 @@ pub fn tessellate_display_list(
sw: f32,
sh: f32,
scale: f32,
-) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>) {
+) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
use crate::scene::paint::{Cap, Prim};
let mut verts: Vec<Vertex> = Vec::new();
let mut batches: Vec<DlBatch> = Vec::new();
let mut images: Vec<DlImage> = Vec::new();
+ // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
+ // they group into: the most recent Plate/Bevel batch, provided only Text
+ // and Image prims (which draw through separate paths anyway) intervene.
+ let mut features: Vec<[f32; 12]> = Vec::new();
+ let mut last_plate: Option<(usize, crate::scene::layout::Rect)> = None;
+
+ // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
+ // shading, plus the frame-constant lighting inputs it pushes per plate.
+ let shader_plates = crate::layout::bevel_shader();
+ let plate_light = {
+ let az = crate::layout::light_source_position();
+ let el = std::f32::consts::FRAC_PI_4; // light elevation above the screen plane
+ [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
+ };
+ // [shading strength (1.0 at the default bevel_depth), specular strength,
+ // shininess, curvature/AO strength] — the plastic material. Curvature is
+ // kept near the raised path's crest amplitude: the recess shoulder's
+ // brightening lands on the same pixels as its specular line, and the two
+ // stack — at 0.5 the step read several times hotter than a plate roll.
+ let plate_mat = [crate::layout::bevel_depth() / 0.15, 0.4, 24.0, 0.2];
for item in &dl.items {
let start = verts.len() as u32;
+ let mut plate: Option<crate::vk::PlatePush> = None;
+ let mut made_plate: Option<crate::scene::layout::Rect> = None;
// Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
let no = item
.clip_circle
@@ -1459,6 +1513,129 @@ pub fn tessellate_display_list(
push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
}
+ Prim::Bevel { rect, radii, color, depth } if shader_plates => {
+ // SDF-lit raised plate: one cover quad; the shader owns fill,
+ // roll shading, corners, and silhouette AA. Nominal corner
+ // radii (scale_corners false): a Bevel is a WIDGET-scale plate
+ // whose silhouette must match the nominal-radius squircles of
+ // the controls around it — only window-scale `Plate`s get the
+ // curvature-matched span.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+ plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false));
+ made_plate = Some(*rect);
+ }
+ Prim::Plate { rect, radii, color, depth } if shader_plates => {
+ // Same lit-plate branch; the cover quad is the exact rect so the
+ // silhouette and the compositor's rounded window corners agree.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+ plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, true));
+ made_plate = Some(*rect);
+ }
+ Prim::Recess { rect, radii, depth, edges }
+ | Prim::Boss { rect, radii, depth, edges }
+ | Prim::Ridge { rect, radii, depth, edges }
+ if shader_plates =>
+ {
+ // Recess carves down into the surface; Boss raises a plateau out
+ // of it (same machinery, depth sign flipped); Ridge is a raised
+ // rim straddling the boundary (its own overlay profile — never
+ // grouped, the CSG features only model monotonic steps).
+ let mode = match &item.prim {
+ Prim::Boss { .. } => 3.0f32,
+ Prim::Ridge { .. } => 4.0,
+ _ => 2.0,
+ };
+ let raised = mode > 2.5;
+ // Grouped into the enclosing plate whenever one is live: the
+ // carve becomes a CSG feature of that plate's single draw —
+ // exact composite shading, real junctions at the plate's rolled
+ // perimeter — instead of a shading overlay (the fallback below).
+ if let Some((bi, prect)) = last_plate.filter(|_| mode < 3.5) {
+ let inside = rect.x >= prect.x - 0.5
+ && rect.y >= prect.y - 0.5
+ && rect.x + rect.width <= prect.x + prect.width + 0.5
+ && rect.y + rect.height <= prect.y + prect.height + 0.5;
+ if inside && features.len() < crate::vk::MAX_PLATE_FEATURES {
+ // A wall the carve shares with the plate's edge extends
+ // past the plate, so the carve has no wall there.
+ let ext = *depth + 4.0;
+ let (mut x0, mut y0) = (rect.x, rect.y);
+ let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
+ if !edges.0 { y0 -= ext; }
+ if !edges.1 { x1 += ext; }
+ if !edges.2 { y1 += ext; }
+ if !edges.3 { x0 -= ext; }
+ let t_px = *depth * scale;
+ // Negative depth = raised (Boss); the shader's summed
+ // slope vectors and curvature sign follow it.
+ let k_px =
+ if raised { -RECESS_DEPTH_RATIO * t_px } else { RECESS_DEPTH_RATIO * t_px };
+ if let Some(p) = batches[bi].plate.as_mut() {
+ if p.host[1] == 0.0 {
+ p.host[0] = features.len() as f32;
+ }
+ p.host[1] += 1.0;
+ }
+ features.push([
+ (x0 + x1) * 0.5 * scale,
+ (y0 + y1) * 0.5 * scale,
+ (x1 - x0) * 0.5 * scale,
+ (y1 - y0) * 0.5 * scale,
+ radii.0 * scale,
+ radii.1 * scale,
+ radii.2 * scale,
+ radii.3 * scale,
+ t_px,
+ k_px,
+ 0.0,
+ 0.0,
+ ]);
+ continue;
+ }
+ }
+ // Overlay-only carve: the cover quad inflates by half the roll
+ // width (the step straddles the boundary) and carries no color —
+ // the shader emits translucent white/black over what's beneath.
+ let infl = *depth * 0.5 + 2.0;
+ verts.extend(quad_vertices(
+ rect.x - infl, rect.y - infl,
+ rect.width + 2.0 * infl, rect.height + 2.0 * infl,
+ sw, sh, [0.0; 4],
+ ));
+ // A suppressed wall is pushed past the cover quad, so its
+ // shading falls outside the drawn pixels (see Prim::Recess on
+ // why a flush region is a step, not a trough).
+ let ext = *depth + 4.0;
+ let (mut x0, mut y0) = (rect.x, rect.y);
+ let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
+ if !edges.0 { y0 -= ext; }
+ if !edges.1 { x1 += ext; }
+ if !edges.2 { y1 += ext; }
+ if !edges.3 { x0 -= ext; }
+ let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
+ let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false);
+ p.mode = mode;
+ // Host-plate box for the roll fade: a suppressed wall means the
+ // recess runs flush to the host's edge there, so that side of
+ // the box sits at the original rect edge; enabled walls face
+ // host interior, pushed to ±1e5 so no fade applies.
+ const FAR: f32 = 1e5;
+ let (hx0, hy0) = (
+ if edges.3 { rect.x - FAR } else { rect.x },
+ if edges.0 { rect.y - FAR } else { rect.y },
+ );
+ let (hx1, hy1) = (
+ if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
+ if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
+ );
+ p.host = [
+ (hx0 + hx1) * 0.5 * scale,
+ (hy0 + hy1) * 0.5 * scale,
+ (hx1 - hx0) * 0.5 * scale,
+ (hy1 - hy0) * 0.5 * scale,
+ ];
+ plate = Some(p);
+ }
Prim::Bevel { rect, radii, color, depth } => {
// Full-size fill: the lip is now a shading overlay, not a paint of the
// outer ring, so the fill must cover the whole rect (the old inset fill
@@ -1499,6 +1676,34 @@ pub fn tessellate_display_list(
EdgeKind::Step, &mut verts,
);
}
+ Prim::Boss { rect, radii, depth, edges } => {
+ // Legacy raised step: the recess overlay with the light sign upright.
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Ridge { rect, radii, depth, edges } => {
+ // Legacy approximation: a raised step up at the boundary plus a
+ // recessed step down half a width in (the banded machinery has no
+ // bump profile; the double-pass hot crest is accepted here — the
+ // legacy path exists only for A/B comparison).
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ let ir = (radii.0 - half).max(0.0);
+ push_bevel_edge_vertices_banded(
+ rect.x + half, rect.y + half,
+ rect.width - *depth, rect.height - *depth,
+ (ir, ir, ir, ir), half,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
}
@@ -1526,16 +1731,88 @@ pub fn tessellate_display_list(
}
}
// Merge into the previous batch if it shares this clip pair and is contiguous.
- if let Some(last) = batches.last_mut() {
- if last.scissor == item.clip && last.clip_rrect == item.clip_rrect && last.end == start {
- last.end = end;
- continue;
+ // Plate batches carry per-draw push constants, so they never merge.
+ if plate.is_none() {
+ // Ordinary geometry painted after a plate ends its carve-grouping
+ // window: a recess emitted later must overlay this geometry (the
+ // fallback path), not shade beneath it inside the plate's draw.
+ last_plate = None;
+ if let Some(last) = batches.last_mut() {
+ if last.plate.is_none()
+ && last.scissor == item.clip
+ && last.clip_rrect == item.clip_rrect
+ && last.end == start
+ {
+ last.end = end;
+ continue;
+ }
}
}
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end });
+ batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate });
+ if let Some(prect) = made_plate {
+ last_plate = Some((batches.len() - 1, prect));
+ }
}
- (verts, batches, images)
+ (verts, batches, images, features)
+}
+
+/// A carve's depth as a fraction of its transition width — must match the
+/// shader's `RECESS_DEPTH` (used by the mode-2 overlay fallback).
+const RECESS_DEPTH_RATIO: f32 = 0.6;
+
+/// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
+/// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
+fn plate_push_raised(
+ rect: &crate::scene::layout::Rect,
+ radii: (f32, f32, f32, f32),
+ width: f32,
+ scale: f32,
+ light: [f32; 3],
+ material: [f32; 4],
+ scale_corners: bool,
+) -> crate::vk::PlatePush {
+ let cap = rect.width.min(rect.height) * 0.5;
+ let shape = crate::layout::corner_shape();
+ // A raw superellipse of exponent n at the circle's nominal radius turns
+ // TIGHTER at the diagonal than that circle — its radius of curvature there
+ // is √2·r / (2^(1/n)·(n − 1)) — and once the roll inset exceeds it, the
+ // offset curve the specular band follows creases into a visible square
+ // corner. For PLATES (`scale_corners`), scale the corner span so the
+ // diagonal curvature radius equals the configured radius: the corner reads
+ // as the same size, entered and exited smoothly (the same reason Apple's
+ // continuous corners run ~1.5·r along the edge), and every inset ≤ r stays
+ // crease-free. Continuous at n = 2, where the factor is exactly 1.
+ // Widget-scale overlay reliefs (recess/boss/ridge fallbacks) pass false:
+ // their radii must MATCH the nominal-radius squircles of the widget
+ // silhouettes around them, and at their few-px roll widths the offset
+ // crease is subpixel.
+ let rscale = if scale_corners && shape > 2.001 {
+ (shape - 1.0) * 2f32.powf(1.0 / shape) / std::f32::consts::SQRT_2
+ } else {
+ 1.0
+ };
+ crate::vk::PlatePush {
+ rect: [
+ (rect.x + rect.width * 0.5) * scale,
+ (rect.y + rect.height * 0.5) * scale,
+ rect.width * 0.5 * scale,
+ rect.height * 0.5 * scale,
+ ],
+ radii: [
+ (radii.0 * rscale).clamp(0.0, cap) * scale,
+ (radii.1 * rscale).clamp(0.0, cap) * scale,
+ (radii.2 * rscale).clamp(0.0, cap) * scale,
+ (radii.3 * rscale).clamp(0.0, cap) * scale,
+ ],
+ light: [light[0], light[1], light[2], width * scale],
+ material,
+ // Mode-1 semantics: [feature offset, feature count] — no carves yet;
+ // the tessellator fills these in as recesses group into this plate.
+ host: [0.0, 0.0, 0.0, 0.0],
+ mode: 1.0,
+ shape,
+ }
}
pub fn extra_quad_vertices(
@@ -2328,12 +2605,12 @@ impl<A: Application> EngineState<A> {
}
}
- let (mut verts, mut dl_batches, dl_images) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
+ let (mut verts, mut dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
// custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
let pre_custom = verts.len() as u32;
self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
if (verts.len() as u32) > pre_custom {
- dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32 });
+ dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32, plate: None });
}
// 1b. Overlay quads (drawn after the text pass).
@@ -2471,6 +2748,7 @@ impl<A: Application> EngineState<A> {
.map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
start: batch.start,
end: batch.end,
+ plate: batch.plate,
})
.collect();
@@ -2496,6 +2774,7 @@ impl<A: Application> EngineState<A> {
batches: &batches,
overlay_verts: &overlay_verts,
images: &image_quads,
+ plate_features: &plate_features,
clear_color,
});
}
diff --git a/src/layout.rs b/src/layout.rs
index 695d061..bc4a467 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -109,6 +109,8 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
"window_manager.light_source_position" => "light_source_position",
"window_manager.bevel_depth" => "bevel_depth",
"window_manager.bevel_width" => "bevel_width",
+ "window_manager.bevel_shader" => "bevel_shader",
+ "window_manager.corner_shape" => "corner_shape",
"style.control.ramp.height" => "ramp_height",
"style.layout.column.gap" => "column_gap",
"style.control.control_panel.padding" => "control_panel_padding",
@@ -1488,6 +1490,24 @@ pub fn bevel_depth() -> f32 {
get_style_registry().read().unwrap().get_float("bevel_depth").unwrap_or(0.15)
}
+/// Corner shape exponent for SDF-lit plates: 2.0 (the default) is a circular
+/// arc; higher values are superellipse "squircle" corners with continuous
+/// curvature — ~4.5 is the Apple-like look. Clamped to [2, 16]: below 2 the
+/// Lp construction degenerates toward a chamfer, above 16 it is visually a
+/// square corner and the pow() terms start flirting with f32 range.
+pub fn corner_shape() -> f32 {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("corner_shape").unwrap_or(2.0).clamp(2.0, 16.0)
+}
+
+/// Whether plates/bevels/recesses render through shader2d's per-pixel SDF-lit
+/// plate branch (the default) or the legacy banded vertex shading. `bevel_shader 0`
+/// in config flips back to the old look for A/B comparison.
+pub fn bevel_shader() -> bool {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("bevel_shader").map(|v| v != 0.0).unwrap_or(true)
+}
+
/// 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
diff --git a/src/main.rs b/src/main.rs
index f715a62..358f377 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -36,6 +36,18 @@ enum DemoMessage {
Exit,
}
+/// Chrome typography: the title/status font sizes, and the layout leaves that
+/// hold them derived as one line-height (×1.2, the toolkit convention) — so the
+/// header and status bands, which anchor to those solved rects, resize with the
+/// typography instead of relying on magic leaf heights.
+const TITLE_FONT_SIZE: f32 = 15.0;
+const STATUS_FONT_SIZE: f32 = 12.0;
+/// Vertical padding on each side of the status band's text line.
+const STATUS_BAND_PAD: f32 = 5.0;
+fn text_leaf_height(font_size: f32) -> f32 {
+ (font_size * 1.2).ceil()
+}
+
struct DemoApp {
// ── Widgets: app-owned values on the narrow-trait adapter. Their addresses must be
// stable across frames (plain struct fields, not Vec elements): the UiContext
@@ -129,16 +141,23 @@ impl Application for DemoApp {
) -> Self {
cce_ui::scale::set_scale_factor(1.0);
Self {
- button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me"),
- toggle: Toggle::new(),
+ button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me").with_raised(true),
+ toggle: Toggle::new().with_raised(true),
// Slider `value` is NORMALIZED 0..1; `with_range` only scales the readout
// (`get_scaled_value`). Wheel nudging is an explicit opt-in.
- slider: Slider::new().with_range(0.0, 100.0).with_value(0.4).with_scroll(true),
- name_box: TextBox::new(String::new()).with_placeholder("Type a name..."),
+ slider: Slider::new()
+ .with_range(0.0, 100.0)
+ .with_value(0.4)
+ .with_scroll(true)
+ .with_raised(true),
+ name_box: TextBox::new(String::new())
+ .with_placeholder("Type a name...")
+ .with_recessed(true),
theme_dropdown: Dropdown::new(
vec!["Forest".into(), "Ocean".into(), "Ember".into()],
0,
- ),
+ )
+ .with_raised(true),
toggle_on: false,
clicks: 0,
status: "Ready.".to_string(),
@@ -215,7 +234,17 @@ impl Application for DemoApp {
let root = arena.insert(LayoutBox::container(
Style::column().padding(16.0).gap(14.0).cross_align(CrossAlign::Stretch),
));
- let title = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 22.0)));
+ let title = arena.insert(LayoutBox::leaf(
+ Style::row(),
+ LSize::new(0.0, text_leaf_height(TITLE_FONT_SIZE)),
+ ));
+ // Clearance under the header band: the recess step rolls over `bevel_width`
+ // past the band's bottom edge, so the first content row must stand off by at
+ // least that or it crowds the carve.
+ let band_gap = arena.insert(LayoutBox::leaf(
+ Style::row(),
+ LSize::new(0.0, cce_ui::layout::bevel_width()),
+ ));
let controls = arena.insert(LayoutBox::container(
Style::row().gap(14.0).height(Length::Fixed(28.0)),
));
@@ -227,8 +256,12 @@ impl Application for DemoApp {
let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
let spacer = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
- let status = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 18.0)));
+ let status = arena.insert(LayoutBox::leaf(
+ Style::row(),
+ LSize::new(0.0, text_leaf_height(STATUS_FONT_SIZE)),
+ ));
arena.append_child(root, title);
+ arena.append_child(root, band_gap);
arena.append_child(root, controls);
arena.append_child(controls, button);
arena.append_child(controls, toggle);
@@ -274,19 +307,44 @@ impl Application for DemoApp {
let w = self.width as f32;
let h = self.height as f32;
- // The window plate — the dissolved root Backplate's exact paint: page-low color
- // at the configured opacity, config corner radius.
+ // The window plate — the dissolved root Backplate as a lit object: page-low color
+ // at the configured opacity, config corner radius, perimeter rolled over
+ // `bevel_width` so the surface reads as a physical plate rather than a flat fill.
let mut plate = cce_ui::color::page_low_color();
if plate[3] > 0.001 {
plate[3] = cce_ui::color::active_backplate_opacity();
}
let radius = cce_ui::colors::backplate_corner_radius();
let frame = Rect { x: 0.0, y: 0.0, width: w, height: h };
- if radius > 0.1 {
- pc.rounded_rect(frame, radius, (true, true, true, true), plate);
- } else {
- pc.quad(frame, plate);
- }
+ let bevel = cce_ui::layout::bevel_width();
+ pc.plate(frame, (radius, radius, radius, radius), plate, bevel);
+
+ // Header band: the title strip carved one step down into the plate. Flush to the
+ // window's top and sides, so its only real wall is the bottom one facing the
+ // content (the recessed-MenuBar idiom — the other three would fight the plate's
+ // own rolled perimeter).
+ let band_h = self.title_rect.y + self.title_rect.height + 10.0;
+ pc.recess_edges(
+ Rect { x: 0.0, y: 0.0, width: w, height: band_h },
+ (0.0, 0.0, 0.0, 0.0),
+ bevel,
+ (false, false, true, false),
+ );
+
+ // Status band: the header's mirror — carved into the bottom of the
+ // plate, flush to the window's bottom and sides, its only wall the top
+ // one facing the content. Emitted here, before any widget geometry, so
+ // it CSG-groups into the plate like the header band does. Sized from
+ // the status font plus a symmetric pad (the layout's status leaf only
+ // reserves the space; the band and its text center independently).
+ let status_h = text_leaf_height(STATUS_FONT_SIZE) + 2.0 * STATUS_BAND_PAD;
+ let status_top = h - status_h;
+ pc.recess_edges(
+ Rect { x: 0.0, y: status_top, width: w, height: status_h },
+ (0.0, 0.0, 0.0, 0.0),
+ bevel,
+ (true, false, false, false),
+ );
// App chrome text: plain prims. `text_with` carries an optional font family and
// optional bounds; unbounded text is clamped to the surface by the engine.
@@ -294,7 +352,7 @@ impl Application for DemoApp {
"cce-ui reference gallery".to_string(),
self.title_rect.x,
self.title_rect.y,
- 15.0,
+ TITLE_FONT_SIZE,
[0xdd, 0xdd, 0xe2],
Some("monospace".to_string()),
None,
@@ -302,10 +360,12 @@ impl Application for DemoApp {
pc.text_with(
self.status.clone(),
self.status_rect.x,
- self.status_rect.y,
- 12.0,
+ cce_ui::layout::align_text_y(status_top, status_h, STATUS_FONT_SIZE, 0.0),
+ STATUS_FONT_SIZE,
[0x9a, 0x9a, 0xa4],
- None,
+ // None here falls through fontconfig's unbundled sans alias to the
+ // serif fallback — always name a family.
+ Some("monospace".to_string()),
None,
);
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 1eee4dd..57cf3b2 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -57,6 +57,21 @@ pub enum Prim {
/// 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, depth: f32, edges: (bool, bool, bool, bool) },
+ /// The inverse of [`Prim::Recess`]: a plateau RAISED out of the surface below.
+ /// Like `Recess` it emits only the shaded edges, never a fill — the face is the
+ /// untouched surface underneath — so a region outlined by raised rolled bumps
+ /// keeps the backplate's own color and translucency. Same wall semantics as
+ /// `Recess` (`edges` = top/right/bottom/left); the lighting is the raised sign,
+ /// so the edges facing `light_source_position` catch the light.
+ Boss { rect: Rect, radii: Radii, depth: f32, edges: (bool, bool, bool, bool) },
+ /// A raised RIM riding the rect's boundary: a bump profile straddling the
+ /// outline (span ±depth/2), rising from the surrounding surface to a crest on
+ /// the boundary and falling back to the same level inside — an elevated border
+ /// around a channel, both faces at the underlying surface's own level. One
+ /// primitive, ONE lighting evaluation per pixel: building the same shape from
+ /// a Boss plus an inset Recess stacks two shading passes (double specular /
+ /// shoulder terms at the crest) and reads far hotter than a plate edge.
+ Ridge { rect: Rect, radii: Radii, 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
@@ -352,6 +367,45 @@ impl PaintCtx {
self.recess_edges(rect, radii, depth, (true, true, true, true));
}
+ /// Raise a plateau out of the already-painted surface below — the inverse of
+ /// [`PaintCtx::recess`]. Only the edges are shaded; the face stays the surface
+ /// beneath, so the raised region inherits the backplate's color. `depth` is the
+ /// roll width in px (pass [`crate::layout::bevel_width`] unless the widget
+ /// needs a tighter lip).
+ pub fn boss(&mut self, rect: Rect, radii: Radii, depth: f32) {
+ self.boss_edges(rect, radii, depth, (true, true, true, true));
+ }
+
+ /// [`PaintCtx::boss`] with only some of the walls — see `Prim::Boss`.
+ pub fn boss_edges(
+ &mut self,
+ rect: Rect,
+ radii: Radii,
+ depth: f32,
+ edges: (bool, bool, bool, bool),
+ ) {
+ let rect = self.apply_offset(rect);
+ self.push(Prim::Boss { rect, radii, depth, edges });
+ }
+
+ /// Raise a rim along `rect`'s boundary — see `Prim::Ridge`. `depth` is the
+ /// full width of the bump (it straddles the outline by ±depth/2).
+ pub fn ridge(&mut self, rect: Rect, radii: Radii, depth: f32) {
+ self.ridge_edges(rect, radii, depth, (true, true, true, true));
+ }
+
+ /// [`PaintCtx::ridge`] with only some of the walls — see `Prim::Ridge`.
+ pub fn ridge_edges(
+ &mut self,
+ rect: Rect,
+ radii: Radii,
+ depth: f32,
+ edges: (bool, bool, bool, bool),
+ ) {
+ let rect = self.apply_offset(rect);
+ self.push(Prim::Ridge { rect, radii, depth, edges });
+ }
+
/// [`PaintCtx::recess`] with only some of the walls — see `Prim::Recess`.
pub fn recess_edges(
&mut self, rect: Rect, radii: Radii, depth: f32,
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 0558ccd..698a1ee 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -40,7 +40,7 @@ mod text;
pub use core::VkCore;
pub use image::{free_image, upload_rgba, ImageQuad};
-pub use renderer::{Batch2D, Frame2D, VkRenderer};
+pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
pub use rt::{RtCamera, RtMaterial, RtOffscreen, RtTriangle};
pub use scene::{MeshId, SceneDraw, Vertex3D};
pub use text::TextSpan;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index c92b9f3..13433f3 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -30,6 +30,35 @@ pub struct Batch2D {
pub clip_rrect: Option<[f32; 5]>,
pub start: u32,
pub end: u32,
+ /// When set, this batch is a single SDF-lit plate cover quad: the params go
+ /// out as push constants and shader2d's plate branch lights it per pixel.
+ pub plate: Option<PlatePush>,
+}
+
+/// Push-constant block for one SDF-lit plate batch (physical px throughout).
+/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub struct PlatePush {
+ /// SDF box: center + half-extents. May extend past the cover quad — that is
+ /// how a recess suppresses a wall.
+ pub rect: [f32; 4],
+ /// Per-corner radii [tl, tr, br, bl].
+ pub radii: [f32; 4],
+ /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
+ pub light: [f32; 4],
+ /// [shading strength, specular strength, shininess, curvature/AO strength].
+ pub material: [f32; 4],
+ /// Mode 1: `[feature offset, feature count, 0, 0]` into the frame's
+ /// `plate_features` — the carves CSG'd out of this plate (the renderer adds
+ /// the frame slot's base offset at record time). Mode 2: the host-plate box
+ /// (center + half-extents) a free recess fades out against; far-away sides
+ /// (±1e5) disable the fade.
+ pub host: [f32; 4],
+ /// 1.0 = raised lit plate, 2.0 = recess overlay.
+ pub mode: f32,
+ /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
+ /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
+ pub shape: f32,
}
/// A full 2D frame: the display-list vertices (optionally split into scissored
@@ -41,10 +70,18 @@ pub struct Frame2D<'a> {
pub overlay_verts: &'a [Vertex],
/// User images drawn interleaved with `verts` by each quad's `z_before`.
pub images: &'a [ImageQuad],
+ /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
+ /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
+ /// Plate batches reference them by offset+count in `PlatePush::host`.
+ pub plate_features: &'a [[f32; 12]],
pub clear_color: [f32; 4],
}
const FRAMES_IN_FLIGHT: usize = 2;
+/// Max plate-carve features per frame; the shader's UBO holds one slot of this
+/// size per frame in flight.
+pub const MAX_PLATE_FEATURES: usize = 64;
+const PLATE_FEATURE_BYTES: usize = 48;
pub(crate) struct AllocatedBuffer {
pub(crate) buffer: vk::Buffer,
@@ -152,6 +189,7 @@ pub struct VkRenderer {
descriptor_set: vk::DescriptorSet,
backdrop_sampler: vk::Sampler,
window_info: AllocatedBuffer,
+ plate_features: AllocatedBuffer,
frames: Vec<Frame>,
frame_index: usize,
@@ -445,6 +483,11 @@ impl VkRenderer {
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::FRAGMENT),
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(3)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT),
];
let descriptor_set_layout = device
.create_descriptor_set_layout(
@@ -454,12 +497,13 @@ impl VkRenderer {
.expect("Failed to create descriptor set layout");
let set_layouts = [descriptor_set_layout];
- // Push constants: the per-batch rounded-rect clip (two vec4s — [cx, cy, bx, by]
- // and [r, enabled, 0, 0]) read by shader2d's fragment stage.
+ // Push constants: the per-batch rounded-rect clip plus the SDF-lit
+ // plate block (seven vec4s, matching shader2d's `RRectClip`), read by
+ // shader2d's fragment stage.
let push_ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.offset(0)
- .size(32)];
+ .size(112)];
let pipeline_layout = device
.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default()
@@ -593,6 +637,15 @@ impl VkRenderer {
vk::BufferUsageFlags::UNIFORM_BUFFER,
"window-info",
);
+ // Plate-carve features, one MAX_PLATE_FEATURES slot per frame in
+ // flight so a write never races the previous frame's reads.
+ let plate_features = create_cpu_buffer(
+ &device,
+ allocator,
+ (FRAMES_IN_FLIGHT * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES) as vk::DeviceSize,
+ vk::BufferUsageFlags::UNIFORM_BUFFER,
+ "plate-features",
+ );
let pool_sizes = [
vk::DescriptorPoolSize::default()
@@ -603,7 +656,7 @@ impl VkRenderer {
.descriptor_count(1),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::UNIFORM_BUFFER)
- .descriptor_count(1),
+ .descriptor_count(2),
];
let descriptor_pool = device
.create_descriptor_pool(
@@ -629,6 +682,10 @@ impl VkRenderer {
.buffer(window_info.buffer)
.offset(0)
.range(16)];
+ let feature_infos = [vk::DescriptorBufferInfo::default()
+ .buffer(plate_features.buffer)
+ .offset(0)
+ .range((FRAMES_IN_FLIGHT * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES) as vk::DeviceSize)];
device.update_descriptor_sets(
&[
vk::WriteDescriptorSet::default()
@@ -646,6 +703,11 @@ impl VkRenderer {
.dst_binding(2)
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
.buffer_info(&buffer_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set)
+ .dst_binding(3)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .buffer_info(&feature_infos),
],
&[],
);
@@ -709,6 +771,7 @@ impl VkRenderer {
descriptor_set,
backdrop_sampler,
window_info,
+ plate_features,
frames,
frame_index: 0,
text,
@@ -1048,6 +1111,7 @@ impl VkRenderer {
batches: &[],
overlay_verts: &[],
images: &[],
+ plate_features: &[],
clear_color: [0.0; 4],
})
}
@@ -1096,6 +1160,18 @@ impl VkRenderer {
self.core.device.reset_fences(&[in_flight]).unwrap();
+ // Upload this frame's plate carves into its slot of the feature
+ // UBO (the slot's previous user has fenced, so no race).
+ if !frame2d.plate_features.is_empty() {
+ let n = frame2d.plate_features.len().min(MAX_PLATE_FEATURES);
+ let base = frame_index * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES;
+ if let Some(allocation) = self.plate_features.allocation.as_mut() {
+ let bytes: &[u8] = bytemuck::cast_slice(&frame2d.plate_features[..n]);
+ allocation.mapped_slice_mut().unwrap()[base..base + bytes.len()]
+ .copy_from_slice(bytes);
+ }
+ }
+
// Upload display-list + overlay vertices into this frame's buffer
// (its fence has signaled, so the GPU is done with it; growing swaps
// in a fresh buffer). Overlay verts sit after the main range.
@@ -1312,8 +1388,13 @@ impl VkRenderer {
order.sort_by_key(|&k| images[k].z_before);
let mut img_i = 0usize;
- let default_batch =
- [Batch2D { scissor: None, clip_rrect: None, start: 0, end: frame.vertex_count }];
+ let default_batch = [Batch2D {
+ scissor: None,
+ clip_rrect: None,
+ start: 0,
+ end: frame.vertex_count,
+ plate: None,
+ }];
let batches: &[Batch2D] =
if frame2d.batches.is_empty() { &default_batch } else { frame2d.batches };
@@ -1377,10 +1458,28 @@ impl VkRenderer {
self.core.device
.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
self.core.device.cmd_set_scissor(cmd, 0, &[scissor]);
- // Per-batch rounded-rect clip (fragments outside discard).
+ // Per-batch rounded-rect clip (fragments outside
+ // discard) + the SDF-lit plate block when this
+ // batch is a plate cover quad.
let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
- let pc = [rr[0], rr[1], rr[2], rr[3], rr[4], enabled, 0.0, 0.0];
+ let mut pc = [0.0f32; 28];
+ pc[..5].copy_from_slice(&rr);
+ pc[5] = enabled;
+ if let Some(p) = &batch.plate {
+ pc[6] = p.mode;
+ pc[7] = p.shape;
+ pc[8..12].copy_from_slice(&p.rect);
+ pc[12..16].copy_from_slice(&p.radii);
+ pc[16..20].copy_from_slice(&p.light);
+ pc[20..24].copy_from_slice(&p.material);
+ pc[24..28].copy_from_slice(&p.host);
+ if p.mode == 1.0 {
+ // Rebase the feature offset onto this
+ // frame's UBO slot.
+ pc[24] += (frame_index * MAX_PLATE_FEATURES) as f32;
+ }
+ }
self.core.device.cmd_push_constants(
cmd,
self.pipeline_layout,
@@ -1428,8 +1527,9 @@ impl VkRenderer {
);
self.core.device
.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
- // Push constants persist across binds — clear any batch's rounded clip.
- let pc = [0.0f32; 8];
+ // Push constants persist across binds — clear any batch's
+ // rounded clip and plate mode.
+ let pc = [0.0f32; 28];
self.core.device.cmd_push_constants(
cmd,
self.pipeline_layout,
@@ -1516,8 +1616,11 @@ impl Drop for VkRenderer {
}
}
let mut window_info = std::mem::replace(&mut self.window_info, AllocatedBuffer::null());
+ let mut plate_features =
+ std::mem::replace(&mut self.plate_features, AllocatedBuffer::null());
if let Some(allocator) = self.core.allocator.as_mut() {
destroy_cpu_buffer(&self.core.device, allocator, &mut window_info);
+ destroy_cpu_buffer(&self.core.device, allocator, &mut plate_features);
}
self.core.device.destroy_descriptor_pool(self.descriptor_pool, None);
@@ -1534,3 +1637,13 @@ impl Drop for VkRenderer {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ /// The WGSL shaders compile at process start, so a syntax or validation
+ /// error is a runtime panic in every client — catch it headlessly here.
+ #[test]
+ fn shader2d_compiles() {
+ assert!(!super::shader2d_spirv().is_empty());
+ }
+}
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 5cd6199..b736f02 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -16,6 +16,23 @@ struct WindowInfo {
@group(0) @binding(2) var<uniform> window_info: WindowInfo;
+// One carve (recess) belonging to an SDF-lit plate: a rounded box subtracted
+// from the plate's material. rect = center + half-extents, radii per-corner
+// (both physical px; a wall the carve shares with the plate's edge is encoded
+// by extending the box past the plate on that side). params = [transition
+// width px, depth px, 0, 0].
+struct PlateFeature {
+ rect: vec4f,
+ radii: vec4f,
+ params: vec4f,
+}
+// Double-buffered by frame-in-flight: slot k's 64 entries belong to frame
+// index k. The plate's push constants carry the absolute offset.
+struct PlateFeatures {
+ items: array<PlateFeature, 128>,
+}
+@group(0) @binding(3) var<uniform> plate_features: PlateFeatures;
+
fn is_outside_window_corners(pos: vec2<f32>) -> bool {
let w = window_info.window_size.x;
let h = window_info.window_size.y;
@@ -55,14 +72,255 @@ fn is_outside_window_corners(pos: vec2<f32>) -> bool {
return false;
}
-// Per-batch rounded-rect clip: rect0 = [cx, cy, bx, by] (center + SDF half-extents),
-// rect1 = [corner radius, enabled flag, 0, 0]. Physical pixels, like clip_position.
+// Per-batch push constants (112 bytes). The first two vec4s are the rounded-rect
+// clip: rect0 = [cx, cy, bx, by] (center + SDF half-extents), rect1 = [corner
+// radius, enabled flag, plate mode, plate corner shape]. When plate mode is
+// nonzero the batch is an SDF-lit plate (1 = raised plate, 2 = recess overlay)
+// and the p_* block describes it; the corner shape exponent selects circular
+// (2) vs superellipse (> 2) plate corners — see plate_sdf_grad. Physical
+// pixels, like clip_position.
struct RRectClip {
rect0: vec4f,
rect1: vec4f,
+ // Plate SDF box: center + half-extents. May extend past the drawn cover
+ // quad — that is how a recess suppresses a wall (the edge lies outside the
+ // covered pixels, so its shading never lands).
+ p_rect: vec4f,
+ // Per-corner radii [tl, tr, br, bl].
+ p_radii: vec4f,
+ // xyz = unit vector toward the light (screen space, +z out of the screen),
+ // w = bevel roll width in px.
+ p_light: vec4f,
+ // [shading strength, specular strength, shininess, curvature/AO strength].
+ p_mat: vec4f,
+ // Mode 1 (raised plate): xy = [offset, count] into plate_features — the
+ // carves CSG'd out of this plate's material.
+ // Mode 2 (free recess overlay): the host-plate box (center + half-extents)
+ // the carve fades out against — a wall flush with the host's edge dies
+ // across the host's perimeter roll; far-away sides sit at ±1e5 (no fade).
+ p_host: vec4f,
}
var<push_constant> rrect_clip: RRectClip;
+const TAU: f32 = 6.28318530718;
+// Ambient floor of the plate lighting model: the fraction of illumination that
+// arrives from everywhere rather than from the directional light. Keeps shadow
+// walls readable instead of crushing to black.
+const PLATE_AMBIENT: f32 = 0.55;
+// Amplitude of the bright crest line hugging a raised plate's silhouette — the
+// ambient-catching convex rim that makes glass read as glass.
+const PLATE_CREST: f32 = 0.25;
+// Recess depth as a fraction of the roll width (a recess is visually shallower
+// than a raised plate's full quarter-round).
+const RECESS_DEPTH: f32 = 0.6;
+
+// Signed distance and gradient of the plate's rounded box at p, as
+// (grad.x, grad.y, distance). Analytic — no dpdx/dpdy — so the clip discards
+// above the plate branch cannot poison derivative quads, and corners need no
+// special casing: the gradient swings continuously around each arc.
+//
+// rect1.w is the corner shape exponent: 2 = circular arcs; > 2 swaps them for
+// superellipse (Lp-norm) corners — Apple-style continuous curvature, where
+// curvature ramps smoothly to zero at the edge join instead of jumping from
+// 1/r, so the lit roll's highlight sweeps a corner without a G2 kink. The Lp
+// gradient is not unit length, so both the direction and the distance carry a
+// first-order |∇| correction — exact on the boundary, and well within a shade
+// step over the roll's few-px band.
+fn rr_sdf_grad(p: vec2f, prect: vec4f, pradii: vec4f) -> vec3f {
+ let c = p - prect.xy;
+ let side = select(pradii.xw, pradii.yz, c.x > 0.0);
+ let r = select(side.x, side.y, c.y > 0.0);
+ let q = abs(c) - prect.zw + vec2f(r, r);
+ let s = vec2f(select(-1.0, 1.0, c.x >= 0.0), select(-1.0, 1.0, c.y >= 0.0));
+ if (q.x > 0.0 && q.y > 0.0) {
+ let shape = rrect_clip.rect1.w;
+ if (shape > 2.001) {
+ let lp = max(pow(pow(q.x, shape) + pow(q.y, shape), 1.0 / shape), 1e-4);
+ let g = vec2f(pow(q.x / lp, shape - 1.0), pow(q.y / lp, shape - 1.0));
+ let gm = max(length(g), 1e-4);
+ return vec3f(s * g / gm, (lp - r) / gm);
+ }
+ let len = max(length(q), 1e-4);
+ return vec3f(s * q / len, len - r);
+ }
+ if (q.x > q.y) {
+ return vec3f(s.x, 0.0, q.x - r);
+ }
+ return vec3f(0.0, s.y, q.y - r);
+}
+
+// Specular of a roll at tilt `slope` whose outward horizontal facing is along
+// `g`: the profile alignment (how close the roll's tilt is to the half-vector's
+// tilt) powered by shininess, times a gentle azimuthal falloff, minus the flat
+// face's baseline so the face contributes zero. Deliberately DECOUPLED rather
+// than Blinn-Phong's pow(dot(n, hv), s): coupled, a straight edge's normal can
+// never fully reach the half-vector (it tilts in one plane only) while a corner
+// diagonal's can, so the power function crushes edge lines relative to corner
+// glints and the meeting fattens into a blob that ignores the corner arc.
+// Decoupled, the band keeps constant inset, width, and peak intensity as it
+// sweeps a corner — the highlight follows the silhouette.
+// `sv` is the surface's slope vector — the horizontal part of the unnormalized
+// normal (-∇height, 1): its magnitude is the tilt, its direction the facing.
+fn roll_spec(sv: vec2f) -> f32 {
+ let m = length(sv);
+ if (m < 1e-5) {
+ return 0.0;
+ }
+ let hv = normalize(rrect_clip.p_light.xyz + vec3f(0.0, 0.0, 1.0));
+ let shininess = rrect_clip.p_mat.z;
+ let facing = sv / m;
+ let cos_t = inverseSqrt(1.0 + m * m);
+ let sin_t = m * cos_t;
+ let hxy = length(hv.xy);
+ let prof = cos_t * hv.z + sin_t * hxy; // cos(tilt - half-vector tilt)
+ let az = clamp(dot(facing, hv.xy) / max(hxy, 1e-4), 0.0, 1.0);
+ return rrect_clip.p_mat.y * max(pow(prof, shininess) - pow(hv.z, shininess), 0.0) * az * az;
+}
+
+// Slope of the raised roll's height profile at f (0 at the face join, 1 at the
+// silhouette). Circular (shape 2): a quarter-round h = sqrt(1 - f²) — tangent-
+// continuous with the face but with a curvature JUMP at the join (1/t → 0), the
+// profile-space twin of a circular plan corner. shape > 2 swaps in the matching
+// superellipse quadrant h = (1 - f^n)^(1/n): its curvature ramps to zero at the
+// join, so the roll's shading fades into the face instead of ending on a line.
+// The slope has the closed form (f/h)^(n-1), which IS the circular formula at
+// n = 2 — the same one-exponent generalization as the plan corners.
+fn roll_slope(f: f32) -> f32 {
+ let shape = rrect_clip.rect1.w;
+ if (shape > 2.001) {
+ let h = pow(max(1.0 - pow(f, shape), 1e-4), 1.0 / shape);
+ return pow(f / h, shape - 1.0);
+ }
+ return f / sqrt(max(1.0 - f * f, 1e-4));
+}
+
+// Slope of a carve's transition profile (0 on the surrounding plateau → 1 on
+// the carve floor) at v in [0, 1] across the wall — always >= 0, zero at both
+// ends. The profile is smoothstep normally; smootherstep (zero SECOND
+// derivative at both plateaus) under a continuous-curvature corner_shape —
+// the step's analog of the superellipse roll.
+fn carve_slope(v: f32) -> f32 {
+ if (rrect_clip.rect1.w > 2.001) {
+ let w = v * (1.0 - v);
+ return 30.0 * w * w;
+ }
+ return 6.0 * v * (1.0 - v);
+}
+
+// Per-pixel lighting of a plate. The plate is one composite height field:
+// the host's rolled-edge surface minus every carve's profile, with the carve
+// depth measured RELATIVE to the local surface (a deboss/etch, not a flat
+// milling plane — a flat tool would swallow the perimeter roll wherever a
+// band overlaps it, deleting the plate's own edge shading there). Heights
+// subtract, so slope vectors ADD: the pixel's normal comes from the summed
+// analytic slopes of every feature over it, and the junction where a carve's
+// wall crosses the plate's perimeter roll is the smooth composite of both
+// tilts, ending in the rim notch a real groove leaves. One lighting
+// evaluation per pixel — features never blend in color space. Shading is
+// expressed relative to the flat face (shade ratio 1.0, specular delta 0.0)
+// so the face keeps exactly the app's chosen color.
+fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
+ let gd = rr_sdf_grad(frag, rrect_clip.p_rect, rrect_clip.p_radii);
+ let d = -gd.z; // positive inside the plate, in px
+ let t = max(rrect_clip.p_light.w, 0.001);
+ let l = rrect_clip.p_light.xyz;
+ let strength = rrect_clip.p_mat.x;
+ let flat_shade = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * l.z;
+
+ if (rrect_clip.rect1.z < 1.5) {
+ let aa = clamp(d + 0.5, 0.0, 1.0); // 1px silhouette anti-aliasing
+ if (aa <= 0.0) {
+ discard;
+ }
+ var base = vcol;
+ if (vcol.a < 0.0) {
+ base = resolve_blur(frag, vcol);
+ }
+ let u = clamp(d / t, 0.0, 1.0);
+ let f = 1.0 - u;
+ // Host roll slope vector: vertical at the silhouette, flat where the
+ // roll meets the face — then every carve's slope adds to it, and its
+ // shoulder/fillet ambient term joins the roll's crest.
+ var sv = gd.xy * roll_slope(f);
+ var extra = PLATE_CREST * f * f * f;
+ let f_off = u32(rrect_clip.p_host.x);
+ let f_cnt = u32(rrect_clip.p_host.y);
+ for (var i = 0u; i < f_cnt; i = i + 1u) {
+ let feat = plate_features.items[f_off + i];
+ let fg = rr_sdf_grad(frag, feat.rect, feat.radii);
+ let ft = max(feat.params.x, 0.001);
+ let v = clamp(-fg.z / ft + 0.5, 0.0, 1.0);
+ if (v <= 0.0) {
+ continue;
+ }
+ // params.y (depth) is signed: positive carves down, negative
+ // raises a boss. The slope vector follows automatically; the
+ // shoulder/fillet ambient term flips with it (a boss's convex
+ // shoulder is at the top of its wall, not the bottom).
+ sv += -(feat.params.y / ft) * carve_slope(v) * fg.xy;
+ extra += rrect_clip.p_mat.w * sin(v * TAU) * sign(feat.params.y);
+ }
+ let n = normalize(vec3f(sv, 1.0));
+ let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
+ let shade = 1.0 + (diff / flat_shade - 1.0 + extra) * strength;
+ let spec = roll_spec(sv);
+ return vec4f(base.rgb * shade + vec3f(spec * strength), abs(base.a) * aa);
+ }
+
+ // Free-floating recess, boss, or ridge (one not grouped into a host plate —
+ // e.g. in a widget's own paint): an overlay over whatever is painted
+ // beneath — no fill, no silhouette. Junction behavior here is the heuristic
+ // host-box fade; grouped features get the exact CSG above.
+ // Mode 2 = recess (interior one step DOWN), mode 3 = boss (interior one
+ // step UP) — the same wall with the height sign flipped. Mode 4 = ridge: a
+ // raised bump straddling the boundary, both sides at the base level — ONE
+ // profile evaluation, so its crest carries a single specular/shoulder term
+ // instead of a boss+recess double-stack.
+ // All profiles straddle the boundary (span [-t/2, t/2]). Darkening is exact
+ // multiplicative shading (black at alpha 1 - shade); brightening is a
+ // translucent white screen.
+ let u = clamp(d / t + 0.5, 0.0, 1.0);
+ var slope = 0.0;
+ var curv = 0.0;
+ if (rrect_clip.rect1.z > 3.5) {
+ // Ridge bump: the carve profile mirrored about the boundary (rising
+ // outer half, falling inner half), amplitude halved so the wall tilt
+ // matches a step's despite the doubled profile rate.
+ let w = clamp(select(2.0 * u, 2.0 - 2.0 * u, u > 0.5), 0.0, 1.0);
+ let rising = select(-1.0, 1.0, u <= 0.5);
+ slope = rising * 0.5 * RECESS_DEPTH * 2.0 * carve_slope(w);
+ // Each half-wall is a boss wall: concave fillet at its base, convex
+ // shoulder toward the crest — and ZERO at the plateaus and crest, so
+ // flat ground composites to exactly nothing (a constant term here
+ // tints the whole cover quad).
+ curv = -rrect_clip.p_mat.w * sin(w * TAU);
+ } else {
+ let dir = select(-1.0, 1.0, rrect_clip.rect1.z > 2.5);
+ // The profile slope is carve_slope's family: smoothstep-derived
+ // normally, smootherstep (zero second derivative at the plateaus)
+ // under a continuous-curvature corner_shape — shading eases in and out
+ // instead of starting on a line.
+ slope = dir * RECESS_DEPTH * carve_slope(u);
+ // Curvature: the convex shoulder catches ambient light, the concave
+ // fillet self-occludes — on the outer half for a recess, inner for a
+ // boss.
+ curv = -dir * rrect_clip.p_mat.w * sin(u * TAU);
+ }
+ let sv = gd.xy * slope;
+ let n = normalize(vec3f(sv, 1.0));
+ let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
+ let spec = roll_spec(sv);
+ // Fade the carve out across the host plate's perimeter roll (see p_host).
+ let hb = rrect_clip.p_host;
+ let host_d = min(hb.z - abs(frag.x - hb.x), hb.w - abs(frag.y - hb.y));
+ let att = clamp(host_d / t, 0.0, 1.0);
+ let v = (diff / flat_shade - 1.0 + curv + spec) * strength * att;
+ if (v >= 0.0) {
+ return vec4f(1.0, 1.0, 1.0, min(v, 1.0));
+ }
+ return vec4f(0.0, 0.0, 0.0, min(-v, 1.0));
+}
+
struct VertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) color: vec4f,
@@ -136,32 +394,43 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
}
}
+ // SDF-lit plate batch (mode in the push constants; see plate_shade).
+ if (rrect_clip.rect1.z > 0.5) {
+ return plate_shade(in.clip_position.xy, in.color);
+ }
+
// Blur-behind plate: negative alpha mixes the (blurred) backdrop with the
// plate color at |alpha| opacity.
if (in.color.a < 0.0) {
- let tex_size = vec2f(textureDimensions(t_backdrop));
- let clean_backdrop = textureSample(t_backdrop, s_backdrop, in.clip_position.xy / tex_size);
-
- var blurred = vec4f(0.0);
- var total_weight = 0.0;
-
- // 7x7 Gaussian blur kernel
- for (var x = -3.0; x <= 3.0; x += 1.0) {
- for (var y = -3.0; y <= 3.0; y += 1.0) {
- let offset = vec2f(x, y) * 2.0; // sample every 2 pixels for a wider blur
- let sample_uv = (in.clip_position.xy + offset) / tex_size;
- let weight = exp(-(x*x + y*y) / (2.0 * 2.0 * 2.0));
- blurred += textureSample(t_backdrop, s_backdrop, sample_uv) * weight;
- total_weight += weight;
- }
- }
-
- let backdrop_color = blurred / total_weight;
- let opacity = -in.color.a;
- let plate_color = vec4f(in.color.rgb, 1.0);
- let blurred_plate = mix(backdrop_color, plate_color, opacity);
- return mix(clean_backdrop, blurred_plate, opacity);
+ return resolve_blur(in.clip_position.xy, in.color);
}
return in.color;
}
+
+// Blur-behind resolve for a negative-alpha plate color: the (blurred) backdrop
+// mixed with the plate color at |alpha| opacity.
+fn resolve_blur(pos: vec2f, color: vec4f) -> vec4f {
+ let tex_size = vec2f(textureDimensions(t_backdrop));
+ let clean_backdrop = textureSample(t_backdrop, s_backdrop, pos / tex_size);
+
+ var blurred = vec4f(0.0);
+ var total_weight = 0.0;
+
+ // 7x7 Gaussian blur kernel
+ for (var x = -3.0; x <= 3.0; x += 1.0) {
+ for (var y = -3.0; y <= 3.0; y += 1.0) {
+ let offset = vec2f(x, y) * 2.0; // sample every 2 pixels for a wider blur
+ let sample_uv = (pos + offset) / tex_size;
+ let weight = exp(-(x*x + y*y) / (2.0 * 2.0 * 2.0));
+ blurred += textureSample(t_backdrop, s_backdrop, sample_uv) * weight;
+ total_weight += weight;
+ }
+ }
+
+ let backdrop_color = blurred / total_weight;
+ let opacity = -color.a;
+ let plate_color = vec4f(color.rgb, 1.0);
+ let blurred_plate = mix(backdrop_color, plate_color, opacity);
+ return mix(clean_backdrop, blurred_plate, opacity);
+}
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 81a8a9e..e2ec697 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -33,6 +33,9 @@ pub struct Button {
pub justify: Justification,
label: Option<String>,
hovered: bool,
+ /// Raised style: the background is an SDF-lit `Bevel` plate — fill plus a
+ /// rolled, lit edge — instead of a flat fill + border stroke.
+ raised: bool,
}
impl std::fmt::Debug for Button {
@@ -63,6 +66,7 @@ impl Button {
justify: Justification::Center,
label: None,
hovered: false,
+ raised: false,
}
}
@@ -121,6 +125,12 @@ impl Button {
/// `Adapted::with_label`, which syncs the model's copy via `Paint::sync_label`).
impl Adapted<Button> {
+ /// Raised style: see the `raised` field.
+ pub fn with_raised(mut self, raised: bool) -> Self {
+ self.raised = raised;
+ self
+ }
+
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
@@ -251,6 +261,18 @@ impl Paint for Button {
let radius = crate::layout::button_corner_radius();
let color = self.color();
+ // Raised style: one lit Bevel plate owns fill and edge — the rolled,
+ // lit rim replaces the flat border stroke. A transparent fill degrades
+ // to a Boss: edges-only, the plate below is the button's face (an
+ // opaque hover_color restores the filled bevel on hover).
+ if self.raised {
+ let depth = crate::layout::bevel_width().min(h * 0.2);
+ if color[3] > 0.001 {
+ ctx.bevel(rect, (radius, radius, radius, radius), color, depth);
+ } else {
+ ctx.boss(rect, (radius, radius, radius, radius), depth);
+ }
+ } else
// Background (+ optional configured border), split by radius exactly as the legacy
// `all_rounded_quads` (rounded) / `extra_quads` (square) overrides emitted it.
if radius > 0.0 {
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 0dd87aa..61d43ff 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -226,6 +226,9 @@ pub struct Toggle {
/// Where the label sits across the pill. Mirrors `Button::justify` — same enum, same
/// 8px edge inset — so the two read as one control set wherever they share a column.
justify: Justification,
+ /// Raised style: the background is an SDF-lit `Bevel` plate (fill + rolled
+ /// lit edge) instead of a flat fill; the state gradient composites on top.
+ raised: bool,
}
impl Toggle {
@@ -237,6 +240,7 @@ impl Toggle {
hovered: false,
focused: false,
justify: Justification::Center,
+ raised: false,
})
}
@@ -271,6 +275,12 @@ impl Adapted<Toggle> {
self.justify = justify;
self
}
+
+ /// Raised style: see the `raised` field.
+ pub fn with_raised(mut self, raised: bool) -> Self {
+ self.raised = raised;
+ self
+ }
}
impl Layout for Toggle {
@@ -311,7 +321,17 @@ impl Paint for Toggle {
let radius = crate::layout::toggle_corner_radius();
let bg = colors::toggle_bg_color();
- if radius > 0.0 {
+ if self.raised {
+ // One lit Bevel plate owns fill and edge; the state gradient below
+ // composites over it. Transparent fill degrades to a Boss (edges
+ // only — the plate below is the face).
+ let depth = crate::layout::bevel_width().min(h * 0.2);
+ if bg[3] > 0.001 {
+ ctx.bevel(rect, (radius, radius, radius, radius), bg, depth);
+ } else {
+ ctx.boss(rect, (radius, radius, radius, radius), depth);
+ }
+ } else if radius > 0.0 {
ctx.rounded_rect(rect, radius, (true, true, true, true), bg);
} else {
ctx.quad(rect, bg);
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 8ddc21c..d4b087b 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -109,6 +109,9 @@ pub struct Dropdown {
/// `Backplate` ancestor — the hook that keeps the adjustment after an app dissolves its
/// root Backplate (the walk finds nothing once the widget is parentless).
corner_frame: Option<((f32, f32, f32, f32), f32, (bool, bool, bool, bool))>,
+ /// Raised style: the closed control's background is an SDF-lit `Bevel`
+ /// plate (fill + rolled lit edge) instead of a flat fill + border stroke.
+ raised: bool,
}
impl Dropdown {
@@ -127,6 +130,7 @@ impl Dropdown {
label: None,
hovered: false,
corner_frame: None,
+ raised: false,
})
}
@@ -279,11 +283,27 @@ impl Dropdown {
let y = content.y;
let visual_h = content.height;
- let mut bg_color = colors::dropdown_background_color();
+ let raw_bg = colors::dropdown_background_color();
+ let mut bg_color = raw_bg;
bg_color[3] = 1.0; // Force opaque background to prevent subpixel blending artifacts
let border_color = self.border_color();
let radius = crate::layout::dropdown_corner_radius();
+ // Raised style: one lit Bevel plate owns fill and edge (the concentric
+ // corner_frame adjustment keeps the legacy path — it exists to nest
+ // flat outlines, which a rolled edge replaces). A transparent
+ // configured fill degrades to a Boss: edges only, plate as the face —
+ // judged on the RAW alpha, before the opacity force above.
+ if self.raised && self.corner_frame.is_none() {
+ let depth = crate::layout::bevel_width().min(visual_h * 0.2);
+ let r4 = (radius, radius, radius, radius);
+ if raw_bg[3] > 0.001 {
+ ctx.bevel(Rect { x, y, width: w, height: visual_h }, r4, bg_color, depth);
+ } else {
+ ctx.boss(Rect { x, y, width: w, height: visual_h }, r4, depth);
+ }
+ return;
+ }
if radius <= 0.0 {
ctx.quad(Rect { x, y, width: w, height: visual_h }, border_color);
ctx.quad(
@@ -547,6 +567,12 @@ impl Dropdown {
}
impl Adapted<Dropdown> {
+ /// Raised style: see the `raised` field.
+ pub fn with_raised(mut self, raised: bool) -> Self {
+ self.raised = raised;
+ self
+ }
+
pub fn with_custom_display_text(mut self, text: &str) -> Self {
self.custom_display_text = Some(text.to_string());
self
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index f84020f..67dca38 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -48,6 +48,10 @@ pub struct Slider {
pub editor_state: TextEditorState,
pub just_changed: bool,
label: Option<String>,
+ /// Raised-track style: the track is drawn as a `Boss` outline — raised
+ /// rolled edges on the surface below — instead of a filled background, so
+ /// the plate's own color shows through the unfilled portion.
+ raised: bool,
}
impl Slider {
@@ -65,6 +69,7 @@ impl Slider {
editor_state: TextEditorState::new(String::new()),
just_changed: false,
label: None,
+ raised: false,
})
}
@@ -167,6 +172,12 @@ impl Adapted<Slider> {
self
}
+ /// Raised-track style: see the `raised` field.
+ pub fn with_raised(mut self, raised: bool) -> Self {
+ self.raised = raised;
+ self
+ }
+
pub fn with_value(mut self, val: f32) -> Self {
self.set_value(val);
self
@@ -224,8 +235,16 @@ impl Paint for Slider {
}
};
- // Track.
- rrect(Rect { x: g.track_x, y: g.y, width: g.track_w, height: g.h }, radius, rc, colors::slider_track(), ctx);
+ // Track. Raised style draws no background at all — the plate below is
+ // the track's surface; the ridge ring after the fill delimits it.
+ let track_rect = Rect { x: g.track_x, y: g.y, width: g.track_w, height: g.h };
+ // Ridge wall width for the raised style: capped well below the bar
+ // height, since the ring needs FOUR wall spans (up+down, top+bottom)
+ // plus a usable channel between them.
+ let ridge_t = crate::layout::bevel_width().min(g.h * 0.2);
+ if !self.raised {
+ rrect(track_rect, radius, rc, colors::slider_track(), ctx);
+ }
// Readout box (+ focus border) and its text.
if self.show_readout {
@@ -252,25 +271,57 @@ impl Paint for Slider {
ctx.text(text, rx + 8.0, crate::layout::align_text_y(g.y, g.h, 12.0, 0.0), 12.0, [0xee, 0xee, 0xf0]);
}
- // Fill up to the thumb center.
+ // Fill up to the thumb center. Raised style insets the fill into the
+ // channel (past the falling inner wall), so the liquid sits in the
+ // valley instead of painting over the ridge.
let thumb_x = g.track_x + self.value * (g.track_w - g.thumb_size);
if let Some(fill_color) = colors::slider_fill() {
- let fill_w = (thumb_x + g.thumb_size / 2.0 - g.track_x).max(0.0).min(g.track_w);
- let fill_rad = if rounded { radius.min(g.h / 2.0) } else { radius };
- rrect(Rect { x: g.track_x, y: g.y, width: fill_w, height: g.h }, fill_rad, (true, true, true, true), fill_color, ctx);
+ let (fx, fy, fmax_w, fh) = if self.raised {
+ let inset = 1.5 * ridge_t;
+ (g.track_x + inset, g.y + inset, g.track_w - 2.0 * inset, g.h - 2.0 * inset)
+ } else {
+ (g.track_x, g.y, g.track_w, g.h)
+ };
+ let fill_w = (thumb_x + g.thumb_size / 2.0 - fx).max(0.0).min(fmax_w);
+ let fill_rad = if rounded { radius.min(fh / 2.0) } else { radius };
+ rrect(Rect { x: fx, y: fy, width: fill_w, height: fh }, fill_rad, (true, true, true, true), fill_color, ctx);
+ }
+
+ // Raised rim AFTER the fill so its shading modulates whatever it
+ // crosses: one Ridge prim riding the track boundary — up from the
+ // plate, crest, back down into the channel where the control sits.
+ // A single primitive on purpose: boss + inset recess stacks two
+ // shading passes and the crest reads far hotter than a plate edge.
+ if self.raised {
+ ctx.ridge(track_rect, (radius, radius, radius, radius), 2.0 * ridge_t);
}
- // Thumb.
+ // Thumb. A real Circle prim, not a full-radius rounded rect: rounded
+ // rects follow the DE-wide corner_shape family, and a squircle knob
+ // reads wrong — the thumb should stay round under any corner style.
let thumb_y = g.y + (g.h - g.thumb_size) / 2.0;
let thumb_color = if self.dragging { colors::slider_thumb_drag() } else { colors::slider_thumb() };
- let thumb_rad = if rounded { g.thumb_size / 2.0 } else { 0.0 };
- rrect(
- Rect { x: thumb_x, y: thumb_y, width: g.thumb_size, height: g.thumb_size },
- thumb_rad,
- (true, true, true, true),
- thumb_color,
- ctx,
- );
+ if rounded {
+ // Raised style: the knob sits IN the valley, so its diameter is the
+ // flat channel floor between the ridge walls — drawn size only; the
+ // drag/hit geometry keeps the full thumb_size.
+ let diameter =
+ if self.raised { g.h - 2.0 * ridge_t } else { g.thumb_size };
+ ctx.circle(
+ thumb_x + g.thumb_size / 2.0,
+ thumb_y + g.thumb_size / 2.0,
+ diameter / 2.0,
+ thumb_color,
+ );
+ } else {
+ rrect(
+ Rect { x: thumb_x, y: thumb_y, width: g.thumb_size, height: g.thumb_size },
+ 0.0,
+ (true, true, true, true),
+ thumb_color,
+ ctx,
+ );
+ }
}
}
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 059269a..65b67da 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -83,6 +83,9 @@ pub struct TextBox {
/// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
/// reads geometry between events, which the narrow traits don't otherwise carry.
rect: Rect,
+ /// Recessed style: a `Recess` overlay is carved over the box's own fill —
+ /// an inset well, the input-direction counterpart of the raised controls.
+ recessed: bool,
}
impl TextBox {
@@ -122,6 +125,7 @@ impl TextBox {
label: None,
hovered: false,
rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
+ recessed: false,
})
}
@@ -995,6 +999,12 @@ impl TextBox {
}
impl Adapted<TextBox> {
+ /// Recessed style: see the `recessed` field.
+ pub fn with_recessed(mut self, recessed: bool) -> Self {
+ self.recessed = recessed;
+ self
+ }
+
pub fn with_update_on_type(mut self, update: bool) -> Self {
self.update_on_type = update;
self
@@ -1282,13 +1292,28 @@ impl Paint for TextBox {
if self.draw_bg_border {
let corners = (true, true, true, true);
- ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
- ctx.rounded_rect(
- Rect { x: x + border_w, y: self.rect.y + top + border_w, width: w - 2.0 * border_w, height: visual_h - 2.0 * border_w },
- (radius - border_w).max(0.0),
- corners,
- bg_color,
- );
+ // Recessed + transparent fill: the carve alone defines the
+ // well — the plate below is its floor, so the flat border and
+ // bg rects are skipped entirely. An opaque fill (e.g. the edit
+ // color while editing) draws as usual and gets carved.
+ let bare = self.recessed && bg_color[3] <= 0.001;
+ if !bare {
+ ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
+ ctx.rounded_rect(
+ Rect { x: x + border_w, y: self.rect.y + top + border_w, width: w - 2.0 * border_w, height: visual_h - 2.0 * border_w },
+ (radius - border_w).max(0.0),
+ corners,
+ bg_color,
+ );
+ }
+ if self.recessed {
+ let depth = crate::layout::bevel_width().min(visual_h * 0.2);
+ ctx.recess(
+ Rect { x, y: self.rect.y + top, width: w, height: visual_h },
+ (radius, radius, radius, radius),
+ depth,
+ );
+ }
}
let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
diff --git a/src/widget/model.rs b/src/widget/model.rs
index e9bcba9..54ae2f1 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1236,6 +1236,8 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
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, depth, edges } => ctx.recess_edges(rect, radii, depth, edges),
+ Prim::Boss { rect, radii, depth, edges } => ctx.boss_edges(rect, radii, depth, edges),
+ Prim::Ridge { rect, radii, depth, edges } => ctx.ridge_edges(rect, radii, 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),