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