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

commitef70da18d829e9e9f1e67da8416b2dc533118797
parent5094b7084e
authorLucas Galante <[email protected]>
date2026-08-04 13:46
feat: BevelPreview widget + the (bevel) config type's shared machinery

New widget::input::bevel_preview: a read-only miniature lit cross-section
of a relief profile (plateau → wall → floor) shaped by a
"shoulder,base,bias" knob triple — the (bevel) KDL type's inline preview.
It takes a click and nothing else; hosts answer by opening cce-bevel.

The curve family moves to shared pub fns (bevel_ease, parse_bevel_knobs)
and cce-bevel now consumes them instead of its private copies, so the
editor and every preview render the same curve by construction.

update_kdl_in_memory's annotation whitelist gains "bevel" (and "keybind"):
cce-bevel's Save rewrites the knob keys through write_config_value, which
previously dropped custom string annotations — the (bevel) type would not
have survived its own editor.

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

 src/bin/cce-bevel.rs              |  29 ++---
 src/config.rs                     |   2 +-
 src/widget/input/bevel_preview.rs | 217 ++++++++++++++++++++++++++++++++++++++
 src/widget/input/mod.rs           |   2 +
 src/widget/mod.rs                 |   1 +
 5 files changed, 227 insertions(+), 24 deletions(-)

diff --git a/src/bin/cce-bevel.rs b/src/bin/cce-bevel.rs
index 7ff5fed..af1bf25 100644
--- a/src/bin/cce-bevel.rs
+++ b/src/bin/cce-bevel.rs
@@ -91,21 +91,11 @@ impl ProfileKnobs {
         (self.shoulder.inner().value(), self.base.inner().value(), self.bias.inner().value())
     }
 
-    /// The section's height curve `h(v)`: bias pre-warp, then the rational
-    /// two-exponent ease. Exponents run 0.5 (sharp crease) → 2 (smoothstep,
-    /// the midpoint) → 8 (wide round-over); bias skews the drop early/late.
+    /// The section's height curve `h(v)` — the shared `(bevel)` curve family
+    /// (`cce_ui::widget::bevel_ease`; the BevelPreview swatch draws the same).
     fn eval(&self, v: f32) -> f32 {
         let (s, b, c) = self.values();
-        let a = 2.0 * 4f32.powf(2.0 * s - 1.0);
-        let be = 2.0 * 4f32.powf(2.0 * b - 1.0);
-        let g = 4f32.powf(2.0 * c - 1.0);
-        let w = v.clamp(0.0, 1.0).powf(g);
-        let num = w.powf(a);
-        let den = num + (1.0 - w).powf(be);
-        if den <= f32::EPSILON {
-            return if w > 0.5 { 1.0 } else { 0.0 };
-        }
-        (num / den).clamp(0.0, 1.0)
+        cce_ui::widget::bevel_ease(s, b, c, v)
     }
 
     /// The curve sampled as linear ramp-spec keys — what the renderer LUT
@@ -166,16 +156,9 @@ struct BevelPopup {
     cut_rect: Rect,
 }
 
-/// Parse a saved "shoulder,base,bias" knob triple.
-fn parse_knobs(s: &str) -> Option<(f32, f32, f32)> {
-    let mut it = s.split(',').map(|p| p.trim().parse::<f32>());
-    match (it.next(), it.next(), it.next()) {
-        (Some(Ok(a)), Some(Ok(b)), Some(Ok(c))) => {
-            Some((a.clamp(0.0, 1.0), b.clamp(0.0, 1.0), c.clamp(0.0, 1.0)))
-        }
-        _ => None,
-    }
-}
+/// Parse a saved "shoulder,base,bias" knob triple — the `(bevel)` value
+/// format, shared with the BevelPreview swatch.
+use cce_ui::widget::parse_bevel_knobs as parse_knobs;
 
 /// Draw one profile as a lit cutaway: the material slab (plate color) inside
 /// a dark opening, its surface stroked with segment lighting from the DE's
diff --git a/src/config.rs b/src/config.rs
index 5bb7ec6..797fa20 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -394,7 +394,7 @@ pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str,
     };
 
     if let Some(ref ext_ty) = existing_ty {
-        if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" {
+        if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" || ext_ty == "bevel" || ext_ty == "keybind" {
             kdl_ty = Some(ext_ty.clone());
         }
     }
diff --git a/src/widget/input/bevel_preview.rs b/src/widget/input/bevel_preview.rs
new file mode 100644
index 0000000..ab581bc
--- /dev/null
+++ b/src/widget/input/bevel_preview.rs
@@ -0,0 +1,217 @@
+//! `BevelPreview` — the `(bevel)` config type's inline preview: a miniature
+//! lit cross-section of a relief profile (plateau → wall → floor), shaped by
+//! a "shoulder,base,bias" knob triple. Read-only: it exists to SHOW the
+//! current material and take a click, which hosts (cce-data-editor) answer
+//! by opening the full `cce-bevel` editor. The curve family and knob
+//! semantics are cce-bevel's — see [`bevel_ease`].
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{Cap, PaintCtx};
+use crate::widget::{Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
+
+/// The relief profile curve family shared by `cce-bevel` and this preview:
+/// the two-exponent rational ease `h(w) = w^a / (w^a + (1-w)^b)` over a bias
+/// pre-warp `w = v^g`. Monotone and endpoint-exact; slider midpoints
+/// (0.5, 0.5, 0.5) give a=b=2, g=1 — the analytic smoothstep. Exponents run
+/// 0.5 (sharp crease) → 2 → 8 (wide round-over).
+pub fn bevel_ease(shoulder: f32, base: f32, bias: f32, v: f32) -> f32 {
+    let a = 2.0 * 4f32.powf(2.0 * shoulder - 1.0);
+    let be = 2.0 * 4f32.powf(2.0 * base - 1.0);
+    let g = 4f32.powf(2.0 * bias - 1.0);
+    let w = v.clamp(0.0, 1.0).powf(g);
+    let num = w.powf(a);
+    let den = num + (1.0 - w).powf(be);
+    if den <= f32::EPSILON {
+        return if w > 0.5 { 1.0 } else { 0.0 };
+    }
+    (num / den).clamp(0.0, 1.0)
+}
+
+/// Parse a "shoulder,base,bias" knob triple (the `(bevel)` value format, and
+/// what cce-bevel persists as `profile_knobs` / `edge_knobs`).
+pub fn parse_bevel_knobs(s: &str) -> Option<(f32, f32, f32)> {
+    let mut it = s.split(',').map(|p| p.trim().parse::<f32>());
+    match (it.next(), it.next(), it.next()) {
+        (Some(Ok(a)), Some(Ok(b)), Some(Ok(c))) => {
+            Some((a.clamp(0.0, 1.0), b.clamp(0.0, 1.0), c.clamp(0.0, 1.0)))
+        }
+        _ => None,
+    }
+}
+
+pub struct BevelPreview {
+    knobs: (f32, f32, f32),
+    just_clicked: bool,
+    hovered: bool,
+}
+
+impl BevelPreview {
+    pub fn new() -> Adapted<BevelPreview> {
+        Adapted::new(BevelPreview {
+            knobs: (0.5, 0.5, 0.5),
+            just_clicked: false,
+            hovered: false,
+        })
+    }
+
+    /// Set the previewed profile from a knob-triple value string; anything
+    /// unparsable falls back to the analytic midpoints.
+    pub fn set_knobs_str(&mut self, s: &str) {
+        self.knobs = parse_bevel_knobs(s).unwrap_or((0.5, 0.5, 0.5));
+    }
+
+    pub fn knobs(&self) -> (f32, f32, f32) {
+        self.knobs
+    }
+}
+
+impl Layout for BevelPreview {}
+
+impl Paint for BevelPreview {
+    fn color(&self) -> [f32; 4] {
+        // The well bg is emitted in `paint` (hover-dependent); no base fill.
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        // The opening: a dark well, slightly lifted on hover (the click cue).
+        let radius = 4.0f32;
+        let bg = if self.hovered { [0.12, 0.12, 0.15, 1.0] } else { [0.08, 0.08, 0.10, 1.0] };
+        ctx.rounded_rect(rect, radius, (true, true, true, true), bg);
+
+        // Mini cutaway: plateau band, the wall over a square-ish domain, then
+        // the floor — cce-bevel's `draw_section` reduced to swatch scale.
+        let m = 3.0f32;
+        let x_l = rect.x + m;
+        let x_r = rect.x + rect.width - m;
+        let y_top = rect.y + m + 2.0;
+        let drop = (rect.height - 2.0 * m - 6.0).max(4.0);
+        let y_bot = y_top + drop;
+        let avail = x_r - x_l;
+        let wall_w = drop.min(avail * 0.5);
+        let plateau_w = (avail - wall_w) * 0.45;
+        let (x0, x1) = (x_l + plateau_w, x_l + plateau_w + wall_w);
+
+        let (s, b, c) = self.knobs;
+        let surface_y = |x: f32| -> f32 {
+            if x <= x0 {
+                y_top
+            } else if x <= x1 {
+                y_top + bevel_ease(s, b, c, (x - x0) / wall_w) * drop
+            } else {
+                y_bot
+            }
+        };
+
+        // The slab under the surface, in the plate material's tone.
+        let mut slab = crate::color::page_low_color();
+        slab = [slab[0] * 1.25 + 0.03, slab[1] * 1.25 + 0.03, slab[2] * 1.25 + 0.03, 1.0];
+        let slab_bot = rect.y + rect.height - m;
+        let step = 2.0f32;
+        let mut x = x_l;
+        while x < x_r {
+            let sy = surface_y((x + step / 2.0).min(x_r));
+            let w = step.min(x_r - x);
+            ctx.quad(Rect { x, y: sy, width: w, height: (slab_bot - sy).max(0.0) }, slab);
+            x += step;
+        }
+
+        // The surface stroke, lit per segment under the DE light azimuth —
+        // the same shading the real walls answer to.
+        let az = crate::layout::light_source_position();
+        let (lx, ly) = (az.cos(), -az.sin());
+        let base_c = [0.60f32, 0.65, 0.74];
+        let n_seg = 24usize;
+        let mut prev = (x_l, surface_y(x_l));
+        for i in 1..=n_seg {
+            let x = x_l + (x_r - x_l) * i as f32 / n_seg as f32;
+            let y = surface_y(x);
+            let (dx, dy) = (x - prev.0, y - prev.1);
+            let len = (dx * dx + dy * dy).sqrt().max(1e-3);
+            let (nx, ny) = (dy / len, -dx / len);
+            let lit = (nx * lx + ny * ly) * 0.35;
+            let col = [
+                (base_c[0] + lit).clamp(0.0, 1.0),
+                (base_c[1] + lit).clamp(0.0, 1.0),
+                (base_c[2] + lit).clamp(0.0, 1.0),
+                1.0,
+            ];
+            ctx.vector(prev.0, prev.1, x, y, 1.5, col, Cap::Round);
+            prev = (x, y);
+        }
+    }
+}
+
+impl Input for BevelPreview {
+    fn on_event(&mut self, event: &Event, _ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
+                self.just_clicked = true;
+                true
+            }
+            Event::MouseEnter => {
+                self.hovered = true;
+                false
+            }
+            Event::MouseLeave => {
+                self.hovered = false;
+                false
+            }
+            _ => false,
+        }
+    }
+
+    fn take_click(&mut self) -> bool {
+        std::mem::take(&mut self.just_clicked)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{UiContext, WidgetHost};
+
+    #[test]
+    fn ease_is_monotone_and_endpoint_exact() {
+        for &(s, b, c) in &[(0.5, 0.5, 0.5), (0.0, 1.0, 0.3), (0.9, 0.1, 0.8)] {
+            assert!(bevel_ease(s, b, c, 0.0).abs() < 1e-4);
+            assert!((bevel_ease(s, b, c, 1.0) - 1.0).abs() < 1e-4);
+            let mut last = -1.0f32;
+            for i in 0..=32 {
+                let h = bevel_ease(s, b, c, i as f32 / 32.0);
+                assert!(h >= last - 1e-4, "monotone at ({s},{b},{c})");
+                last = h;
+            }
+        }
+        // Midpoints are the analytic smoothstep.
+        let mid = bevel_ease(0.5, 0.5, 0.5, 0.5);
+        assert!((mid - 0.5).abs() < 1e-4);
+    }
+
+    #[test]
+    fn knob_parse_clamps_and_rejects() {
+        assert_eq!(parse_bevel_knobs("0.2, 0.7, 1.5"), Some((0.2, 0.7, 1.0)));
+        assert_eq!(parse_bevel_knobs("0.2,0.7"), None);
+        assert_eq!(parse_bevel_knobs("junk"), None);
+    }
+
+    #[test]
+    fn click_reports_once() {
+        let mut ctx = UiContext::new();
+        let mut p = BevelPreview::new();
+        let (id, ptr) = (p.id(), p.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, 125.0, 26.0);
+        let ev = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 10.0,
+            y: 10.0,
+            local_x: 10.0,
+            local_y: 10.0,
+        };
+        assert!(ctx.propagate_event(&ev, id));
+        assert!(p.take_click());
+        assert!(!p.take_click());
+    }
+}
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 900eae6..e09028e 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -11,6 +11,7 @@ pub mod font_selector;
 pub mod button_strip;
 pub mod keybind_recorder;
 pub mod ramp;
+pub mod bevel_preview;
 
 pub use button::{Button, ButtonKind, PageButton};
 pub use checkbox::{Checkbox, Toggle};
@@ -25,6 +26,7 @@ pub use font_selector::FontSelector;
 pub use button_strip::ButtonStrip;
 pub use keybind_recorder::KeybindRecorder;
 pub use ramp::{Ramp, RampKey, ColorRamp, ColorRampKey, format_ramp_spec, parse_ramp_spec};
+pub use bevel_preview::{BevelPreview, bevel_ease, parse_bevel_knobs};
 
 pub const BREADCRUMB_PADDING: f32 = 8.0;
 pub const SEGMENT_GAP: f32 = 4.0;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 0dbbc76..58cf458 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -540,6 +540,7 @@ pub use self::core::focus::link_parent_child;
 pub use self::input::{
     Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
     ColorSelector, Finger, Trackpad, get_font_db, ActiveThumb, FontSelector,
+    BevelPreview, bevel_ease, parse_bevel_knobs,
     ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
     format_ramp_spec, parse_ramp_spec
 };