GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
relief: the flat-path bridge carries Button and Toggle carves too
Extends the TextBox bridge to the other two controls whose whole appearance
is relief. A Toggle is the extreme case: it paints NO fill in any style — it
is worked out of the plate it sits on, so light and relief ARE the control —
and `all_quads` carries neither. On a flat host that left a toggle row as a
bare label with nothing to click at all, not merely an undecorated one.
The one-hook-per-widget shape that started with `recess` doesn't scale past
two: a rocker needs per-corner radii, a per-wall mask (its hinge wall is open
so the two halves meet in ONE step rather than two facing walls), and both
step directions. So that hook is replaced, one commit after it landed, by a
carve the widget describes: `RenderTarget::relief_carve(&ReliefCarve)`, with
kind (Recess/Boss), rect, radii, depth and edges. `PaintCtx::carve` is the
single application point — a widget's `paint` carves through it and a flat
host re-emits through it, so the two can only ever draw the same prim.
Geometry keeps coming from the widget, never re-derived in the bridge:
`Button::inset_face`, `Toggle::flat_faces` (the rocker's uniform face-light
overlays, ordinary quads) and `Toggle::flat_carves`, each now the single
source its own `paint` reads too. A Button's face keeps the colour the tuple
hosts already showed; what they were missing is the groove ring around it.
ListRow buttons stay exempt, as in `paint` — a transparent-until-hover
surface would wear a permanent carved ring on every idle row of a list.
Verified in a headless shadow session (CCE_FONTS_DIR set): the settings app's
Record History toggle now draws its rocker where there was nothing but a
label, and the Apply button's top edge carries the trough profile — valley
floor (6,18,8) into a lit lip (194,211,231) before the face colour. cce-files
re-checked as the regression case, since it drives Button through its own
paint walk: its chooser footer rings are unchanged. cce-ui tests 242 pass.
src/layout.rs | 104 +++++++++++++++++++++++++++++++++++++-----
src/scene/paint.rs | 106 ++++++++++++++++++++++++++++++++++++++++---
src/widget/input/button.rs | 22 +++++++--
src/widget/input/checkbox.rs | 90 ++++++++++++++++++++++++++++--------
4 files changed, 282 insertions(+), 40 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index 74b6a4d..55e73c0 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3449,6 +3449,50 @@ pub fn set_rangeslider_height(height: f32) {
}
+
+/// One step carve a widget's `paint` draws, handed to a flat-path host through
+/// [`RenderTarget::relief_carve`] so it can re-emit it as a real prim.
+///
+/// Geometry always comes from the WIDGET (`TextBox::well`, `Toggle::
+/// rocker_reliefs` / `slide_button`), never re-derived here — a second copy of
+/// that math in the bridge is exactly how the flat host's carve and the drawn
+/// one drift apart.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ReliefCarve {
+ pub kind: CarveKind,
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ /// Per-corner radii, clockwise from top-left.
+ pub radii: (f32, f32, f32, f32),
+ /// Full width of the step's transition band.
+ pub depth: f32,
+ /// Which walls the carve has (top, right, bottom, left). A suppressed wall
+ /// means the step runs flush to its neighbour there — the rocker's hinge,
+ /// where the raised half and the recessed one meet in ONE step rather than
+ /// two facing walls.
+ pub edges: (bool, bool, bool, bool),
+}
+
+/// Which way a [`ReliefCarve`] steps.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum CarveKind {
+ /// Interior one step DOWN ([`crate::scene::paint::PaintCtx::recess_edges`]).
+ /// `tint` lights the rim in the focus accent (`recess_tinted`).
+ Recess { tint: Option<[f32; 3]> },
+ /// Interior one step UP ([`crate::scene::paint::PaintCtx::boss_edges`]).
+ Boss,
+}
+
+impl ReliefCarve {
+ /// This carve shifted vertically — the page-scroll adjustment a host
+ /// applies when it re-emits collected carves.
+ pub fn shifted_y(self, dy: f32) -> Self {
+ Self { y: self.y + dy, ..self }
+ }
+}
+
pub trait RenderTarget {
fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32);
fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, _radius: f32) {
@@ -3476,17 +3520,16 @@ pub trait RenderTarget {
fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
self.rect_with_radius(color, x, y, w, h, radius);
}
- /// A sunken well ([`crate::scene::paint::PaintCtx::recess`]) — the carve a
- /// TextBox leaves, offered here for the same reason as `inset_plate`: the
- /// legacy `all_quads` stream carries no relief prims, so a flat-path host
- /// never sees it. `tint` is the focus accent (`recess_tinted`).
+ /// One step carve from a widget's `paint` ([`ReliefCarve`]) — offered here
+ /// for the same reason as `inset_plate`: the legacy `all_quads` stream
+ /// carries no relief prims, so a flat-path host never sees them.
///
- /// The default is deliberately a NO-OP, not a fill: a recessed control's
- /// face is transparent by design (the host surface IS the well floor), so
- /// the carve is the entire decoration — a host that can't carve has
- /// nothing truthful to draw, and a solid box here would paint every text
- /// field a flat slab it never had.
- fn recess(&mut self, _x: f32, _y: f32, _w: f32, _h: f32, _radius: f32, _depth: f32, _tint: Option<[f32; 3]>) {}
+ /// The default is deliberately a NO-OP, not a fill. These controls have
+ /// transparent faces by design (the host surface IS the well floor / the
+ /// rocker plate), so the carve is their entire decoration — a host that
+ /// can't carve has nothing truthful to draw, and a solid box here would
+ /// paint every text field a flat slab it never had.
+ fn relief_carve(&mut self, _carve: &ReliefCarve) {}
/// Whether this host renders sections as sunken wells (the designer idiom).
/// `SectionContext` then lays the title out left-aligned over its tab box
/// instead of centered on the top border.
@@ -3603,7 +3646,46 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
if control_relief() {
if let Some(tb) = w.as_any().downcast_ref::<crate::widget::TextBox>() {
if let Some((well, radius, depth, tint)) = tb.well() {
- pc.recess(well.x, well.y, well.width, well.height, radius, depth, tint);
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Recess { tint },
+ x: well.x,
+ y: well.y,
+ w: well.width,
+ h: well.height,
+ radii: (radius, radius, radius, radius),
+ depth,
+ edges: (true, true, true, true),
+ });
+ }
+ }
+ }
+
+ // A Button's chrome is the Dropdown's: a flush inset plate, so it rides the
+ // same hook. Its face DOES carry a colour (`Button::color`), which the flat
+ // fill the tuple hosts degrade to still shows — what they were missing is
+ // the groove ring around it.
+ if control_relief() {
+ if let Some(btn) = w.as_any().downcast_ref::<crate::widget::Button>() {
+ let brect = crate::scene::layout::Rect { x: wx, y: wy, width: www, height: whh };
+ if let Some((rect, radius, depth, color)) = btn.inset_face(brect) {
+ pc.inset_plate(color, rect.x, rect.y, rect.width, rect.height, radius, depth);
+ }
+ }
+ }
+
+ // A Toggle is the extreme case: it paints NO fill in any style — it is
+ // worked out of the plate it sits on, so relief and light ARE the control.
+ // On a flat host that left the row as a bare label with nothing to click at
+ // all. Both halves of its appearance come across: the face-light overlays
+ // (ordinary quads) and the step carves.
+ if control_relief() {
+ if let Some(tg) = w.as_any().downcast_ref::<crate::widget::Toggle>() {
+ let rect = crate::scene::layout::Rect { x: wx, y: wy, width: www, height: whh };
+ for (face, r, corners, light) in tg.flat_faces(rect) {
+ pc.rect_with_radius_corners(light, face.x, face.y, face.width, face.height, r, corners);
+ }
+ for carve in tg.flat_carves(rect) {
+ pc.relief_carve(&carve);
}
}
}
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 19bd99e..eec3990 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -57,6 +57,58 @@ pub type Radii = (f32, f32, f32, f32);
/// for two things: the `Bevel` prim, and the shared *edge treatment* every
/// relief primitive is shaded with (`bevel_width`, `bevel_depth`,
/// `bevel_shader`, `bevel_profile` — the lit roll, not the shape).
+/// Shape and material knobs for [`Prim::Droplet`]. Fractions are of the
+/// droplet rect's height unless said otherwise, so a spec is resolution- and
+/// module-size-independent; the tessellator resolves and clamps them against
+/// the concrete rect.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct DropletSpec {
+ /// How far the sheet's bottom lifts above the rect bottom (the waist the
+ /// sides pull up into), fraction of height. 0 = no waist (a capsule).
+ pub sag: f32,
+ /// Belly capsule radius, fraction of height.
+ pub belly: f32,
+ /// Belly half-width, fraction of the half-width left after the belly
+ /// radius (1 = the belly spans the whole bottom).
+ pub belly_w: f32,
+ /// Smooth-union blend distance, fraction of height — bigger = softer neck
+ /// between sheet and belly.
+ pub blend: f32,
+ /// Sheet bottom-corner radius, fraction of height.
+ pub sheet_r: f32,
+ /// Tint opacity at the deep interior relative to the color's own alpha;
+ /// the rim falls toward `clarity` × that (thin water is clearer). 1 = flat.
+ pub clarity: f32,
+ /// Dome slope amplitude: scales the surface tilt the shading sees.
+ pub dome: f32,
+ /// Shaded band width (the dome's curved skirt), fraction of height.
+ pub band: f32,
+ /// Specular (gleam) strength — replaces the DE material's slot.
+ pub gleam: f32,
+ /// Wet-surface shininess exponent.
+ pub shine: f32,
+ /// Fresnel rim crest amplitude (the glass-edge brightening).
+ pub rim: f32,
+}
+
+impl Default for DropletSpec {
+ fn default() -> Self {
+ Self {
+ sag: 0.45,
+ belly: 0.75,
+ belly_w: 0.85,
+ blend: 0.35,
+ sheet_r: 0.3,
+ clarity: 0.55,
+ dome: 1.0,
+ band: 0.9,
+ gleam: 1.2,
+ shine: 24.0,
+ rim: 0.35,
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq)]
pub enum Prim {
Quad { rect: Rect, color: [f32; 4] },
@@ -147,6 +199,19 @@ pub enum Prim {
/// plate's face keeps the app's color. Falls back to a flat circle on the
/// legacy (`bevel_shader 0`) path.
Sphere { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
+ /// A hanging water droplet clinging to the TOP edge of `rect`, lit per pixel
+ /// by shader mode 10: the silhouette is a smooth union of a film "sheet"
+ /// attached to the top edge (square top corners — the attach line) and a
+ /// belly capsule resting on the rect's bottom, blended metaball-style so a
+ /// waist forms where the sides pull up. Shaded as a glass dome under the
+ /// DE's plate light — same ambient/diffuse and decoupled specular as the
+ /// plates, plus a fresnel rim crest and a thin-edge clarity falloff (tint
+ /// opacity drops toward the silhouette, so the frosted backdrop shows
+ /// through clearer at the rim, which is what reads as water rather than
+ /// plastic). Shape knobs in [`DropletSpec`]. On the legacy (`bevel_shader
+ /// 0`) path it degrades to the flat hanging capsule — square top, round
+ /// bottom — rather than vanishing.
+ Droplet { rect: Rect, color: [f32; 4], spec: DropletSpec },
/// A concave inside-corner fillet for composed carves: a quarter-arc wall
/// whose centre `(cx, cy)` sits out in the corner's pocket, shaded with the
/// same step profile as a `Recess`/`Boss` wall (`raised` flips the sign).
@@ -462,6 +527,13 @@ impl PaintCtx {
self.push(Prim::Sphere { cx: cx + ox, cy: cy + oy, radius, color });
}
+ /// A hanging water droplet clinging to `rect`'s top edge — see
+ /// [`Prim::Droplet`] and [`DropletSpec`].
+ pub fn droplet(&mut self, rect: Rect, color: [f32; 4], spec: DropletSpec) {
+ let rect = self.apply_offset(rect);
+ self.push(Prim::Droplet { rect, color, spec });
+ }
+
/// A concave inside-corner fillet — see `Prim::ConcaveFillet`. `start` is
/// the quarter wedge's start angle; the arc's centre sits in the corner's
/// pocket and the wall descends (or rises, `raised`) away from it.
@@ -518,6 +590,7 @@ impl PaintCtx {
}
Prim::Circle { cx, cy, radius, color } => self.circle(cx, cy, radius, color),
Prim::Sphere { cx, cy, radius, color } => self.sphere(cx, cy, radius, color),
+ Prim::Droplet { rect, color, spec } => self.droplet(rect, color, spec),
Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
self.concave_fillet(cx, cy, radius, depth, start, raised)
}
@@ -636,6 +709,30 @@ impl PaintCtx {
self.trough(rect, radii, depth);
}
+ /// Emit one [`crate::layout::ReliefCarve`]. The shared application point:
+ /// a widget's `paint` carves through here, and a flat host re-emits the
+ /// carves it collected through here too, so the two can only ever draw the
+ /// same prim.
+ ///
+ /// A tinted recess takes `recess_tinted`, which lights the whole rim — it
+ /// is the focus treatment, and every tinted carve the toolkit emits is a
+ /// full ring. A partial ring falls back to the untinted walls rather than
+ /// silently tinting walls the caller suppressed.
+ pub fn carve(&mut self, c: &crate::layout::ReliefCarve) {
+ let rect = Rect { x: c.x, y: c.y, width: c.w, height: c.h };
+ match c.kind {
+ crate::layout::CarveKind::Boss => self.boss_edges(rect, c.radii, c.depth, c.edges),
+ crate::layout::CarveKind::Recess { tint: Some(t) }
+ if c.edges == (true, true, true, true) =>
+ {
+ self.recess_tinted(rect, c.radii, c.depth, t)
+ }
+ crate::layout::CarveKind::Recess { .. } => {
+ self.recess_edges(rect, c.radii, c.depth, c.edges)
+ }
+ }
+ }
+
/// Sink a valley along `rect`'s boundary — see [`Prim::Trough`]. `depth` is
/// the full width of the seam (it straddles the outline by ±depth/2).
pub fn trough(&mut self, rect: Rect, radii: Radii, depth: f32) {
@@ -881,13 +978,8 @@ impl crate::layout::RenderTarget for PaintCtx {
fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32) {
PaintCtx::inset_plate(self, Rect { x, y, width: w, height: h }, (radius, radius, radius, radius), color, depth);
}
- fn recess(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, tint: Option<[f32; 3]>) {
- let rect = Rect { x, y, width: w, height: h };
- let radii = (radius, radius, radius, radius);
- match tint {
- Some(t) => PaintCtx::recess_tinted(self, rect, radii, depth, t),
- None => PaintCtx::recess(self, rect, radii, depth),
- }
+ fn relief_carve(&mut self, carve: &crate::layout::ReliefCarve) {
+ PaintCtx::carve(self, carve);
}
}
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 9290334..0b0bd71 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -139,6 +139,23 @@ impl Button {
crate::widget::display::measure_text_width(label, &family, size)
}
}
+
+ /// The flush inset plate this Button's `paint` draws, as `(rect, corner
+ /// radius, depth, face colour)` — `None` when it draws none (flat styling,
+ /// or a ListRow, which is a transparent-until-hover surface and would wear
+ /// a permanent carved ring on every idle row).
+ ///
+ /// The single source `paint` and the flat-path bridge in
+ /// `layout::render_widget` both read, so a flat host's groove can't drift
+ /// from the drawn one.
+ pub fn inset_face(&self, rect: Rect) -> Option<(Rect, f32, f32, [f32; 4])> {
+ if !self.raised || self.kind == ButtonKind::ListRow {
+ return None;
+ }
+ let radius = crate::layout::button_corner_radius();
+ let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+ Some((rect, radius, depth, self.color()))
+ }
}
/// The by-value builder chain, mirrored on the wrapped type (`with_label` comes from the generic
@@ -296,9 +313,8 @@ impl Paint for Button {
// List rows are exempt: they are transparent-until-hover/selected
// surfaces, and the edges-only groove would stack a permanent carved
// ring on every idle row of a list.
- if self.raised && self.kind != ButtonKind::ListRow {
- let depth = crate::layout::bevel_width().min(h * 0.2);
- ctx.inset_plate(rect, (radius, radius, radius, radius), color, depth);
+ if let Some((face, r, depth, c)) = self.inset_face(rect) {
+ ctx.inset_plate(face, (r, r, r, r), c, depth);
} else {
// ListRow also skips the border idiom below: it draws the border
// color as a FULL rect with the fill inset over it, which only
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 238420f..21af135 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -304,6 +304,71 @@ impl Toggle {
[(state.0, state.1, state.2, true), (other.0, other.1, other.2, false)]
}
+ /// The face-light overlays this Toggle paints: `(rect, radius, corners,
+ /// color)`. The rocker's two half faces carry them; the slide style has
+ /// none (its glider is pure relief). Empty when the light works out to
+ /// nothing.
+ ///
+ /// The single source `paint` and the flat-path bridge in
+ /// `layout::render_widget` both read — a Toggle paints NO fill in any
+ /// style, so on a flat host these overlays plus [`Toggle::flat_carves`]
+ /// are the ENTIRE control; without them the row was a bare label.
+ pub fn flat_faces(&self, rect: Rect) -> Vec<(Rect, f32, (bool, bool, bool, bool), [f32; 4])> {
+ if crate::layout::toggle_slide() {
+ return Vec::new();
+ }
+ let radius = crate::layout::toggle_corner_radius();
+ self.rocker_reliefs(rect)
+ .into_iter()
+ .filter_map(|(half, radii, _, _)| {
+ let light = self.face_light(radii.0 > 0.0);
+ if light[3] <= 0.001 {
+ return None;
+ }
+ let corners = (radii.0 > 0.0, radii.1 > 0.0, radii.2 > 0.0, radii.3 > 0.0);
+ Some((half, radius, corners, light))
+ })
+ .collect()
+ }
+
+ /// The step carves this Toggle paints — the glider's raised rim in the
+ /// slide style, the rocker's raised/recessed halves otherwise (only under
+ /// `raised` styling; without it the faces' light stands alone). Companion
+ /// to [`Toggle::flat_faces`]; see there for why both exist.
+ pub fn flat_carves(&self, rect: Rect) -> Vec<crate::layout::ReliefCarve> {
+ use crate::layout::{CarveKind, ReliefCarve};
+ let radius = crate::layout::toggle_corner_radius();
+ let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+ if let Some(btn) = self.slide_button(rect) {
+ return vec![ReliefCarve {
+ kind: CarveKind::Boss,
+ x: btn.x,
+ y: btn.y,
+ w: btn.width,
+ h: btn.height,
+ radii: (radius, radius, radius, radius),
+ depth,
+ edges: (true, true, true, true),
+ }];
+ }
+ if !self.raised {
+ return Vec::new();
+ }
+ self.rocker_reliefs(rect)
+ .into_iter()
+ .map(|(half, radii, walls, raised)| ReliefCarve {
+ kind: if raised { CarveKind::Boss } else { CarveKind::Recess { tint: None } },
+ x: half.x,
+ y: half.y,
+ w: half.width,
+ h: half.height,
+ radii,
+ depth,
+ edges: walls,
+ })
+ .collect()
+ }
+
/// A rocker face's UNIFORM lighting overlay, evaluated under the SAME DE
/// light the relief primitives answer to: `light_source_position` through the plate
/// model (shader2d's `plate_shade` — ambient floor, diffuse off the
@@ -397,7 +462,6 @@ impl Paint for Toggle {
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
- let radius = crate::layout::toggle_corner_radius();
let slide = crate::layout::toggle_slide();
// A toggle paints NO fill of its own, in any style: it is worked out
@@ -413,9 +477,8 @@ impl Paint for Toggle {
// beveled rim and its position ARE the read (left off, right on).
// The rim also reaches legacy-view hosts through `slide_button`
// (see `ParametersBg::reliefs`).
- if let Some(btn) = self.slide_button(rect) {
- let depth = crate::layout::bevel_width().min(h * 0.2);
- ctx.boss_edges(btn, (radius, radius, radius, radius), depth, (true, true, true, true));
+ for carve in self.flat_carves(rect) {
+ ctx.carve(&carve);
}
} else {
// The rocker: two FLAT half faces (see `rocker_reliefs`) — the
@@ -428,22 +491,11 @@ impl Paint for Toggle {
// flat style's hue gradient is gone with the rest of the palette,
// so the two styles now differ only by the relief they were named
// for.
- for (half, radii, _, _) in self.rocker_reliefs(rect) {
- let light = self.face_light(radii.0 > 0.0);
- if light[3] > 0.001 {
- let corners = (radii.0 > 0.0, radii.1 > 0.0, radii.2 > 0.0, radii.3 > 0.0);
- ctx.rounded_rect(half, radius, corners, light);
- }
+ for (half, r, corners, light) in self.flat_faces(rect) {
+ ctx.rounded_rect(half, r, corners, light);
}
- if self.raised {
- let depth = crate::layout::bevel_width().min(h * 0.2);
- for (half, radii, walls, raised) in self.rocker_reliefs(rect) {
- if raised {
- ctx.boss_edges(half, radii, depth, walls);
- } else {
- ctx.recess_edges(half, radii, depth, walls);
- }
- }
+ for carve in self.flat_carves(rect) {
+ ctx.carve(&carve);
}
}