GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: ramp-defined bevel profiles — carve walls reshape from a user curve
The shape of every recess/boss/ridge wall was baked into the shader as a
smoothstep. It is now definable as a ramp curve:
- layout::set_bevel_profile_keys(keys, smooth) samples the curve (the exact
interpolation the Ramp widget draws) into a 32-entry slope LUT; a
generation counter tells renderers to re-upload.
- The 2D shader's WindowInfo UBO grows a profile block (meta + 8 vec4 of
slope samples); carve_slope() lerps the LUT when installed, keeping the
analytic smoothstep/smootherstep as the default. Non-monotonic curves
(rims, ogees) work — slopes may go negative and need not integrate to 1.
- Ramp gains spec-string serialization (format_ramp_spec/parse_ramp_spec,
"smooth;0.000:0.000,1.000:1.000") wired through Input::value_string, so a
ramp can live wherever string-valued params travel.
- ParametersBg gains a "ramp" row type: an Adapted<Ramp> per row, seeded
from the value spec, re-serialized on every edit (click/drag/preset/tick).
The curve/key geometry no flat tuple view can carry rides the new
paint_scene_rows(pc) hook, which legacy-hatch hosts call inside their
scroll clip; the ramp's field dropdowns join the popover surfaces.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/layout.rs | 112 ++++++++++++++++++++++++++
src/vk/renderer.rs | 39 ++++++---
src/vk/shader2d.wgsl | 29 ++++++-
src/widget/container/parameters_bg.rs | 145 +++++++++++++++++++++++++++++++++-
src/widget/input/mod.rs | 2 +-
src/widget/input/ramp.rs | 79 +++++++++++++++++-
src/widget/mod.rs | 3 +-
7 files changed, 392 insertions(+), 17 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index 045dde0..126a9ae 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1539,6 +1539,91 @@ pub fn bevel_width() -> f32 {
get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(9.3)
}
+/// Sample count of the custom bevel profile LUT ([`set_bevel_profile_keys`]).
+pub const BEVEL_PROFILE_SAMPLES: usize = 32;
+
+/// The custom bevel/carve height profile, as the slope LUT the renderer uploads
+/// to the 2D shader: slot `i` holds `h'` at `v = (i + 0.5) / N` of the wall's
+/// height curve `h(v)` (`v` runs 0 at the surrounding plateau → 1 at the carve
+/// floor / boss crest; `h` in units of the feature's depth, so a 0→1 curve is
+/// the classic full-depth bevel and a curve ending back at its start height is
+/// a pure decorative rim). `None` = the analytic smoothstep profile.
+static BEVEL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
+ std::sync::RwLock::new(None);
+/// Bumped on every profile change so renderers know to re-upload their LUT.
+static BEVEL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
+/// Evaluate a ramp key list at `t` — the same piecewise interpolation
+/// `cce_ui::widget::Ramp::get_interpolated_value` draws, so the bevel renders
+/// exactly the curve the ramp widget shows (`smooth` = the widget's Bezier line
+/// type: smoothstep blending between keys; else linear).
+pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
+ let Some(first) = keys.first() else { return 0.0 };
+ let last = keys.last().unwrap();
+ if t <= first.0 {
+ return first.1;
+ }
+ if t >= last.0 {
+ return last.1;
+ }
+ for pair in keys.windows(2) {
+ let (k1, k2) = (pair[0], pair[1]);
+ if t >= k1.0 && t <= k2.0 {
+ let range = k2.0 - k1.0;
+ if range.abs() < 0.0001 {
+ return k1.1;
+ }
+ let mut w = (t - k1.0) / range;
+ if smooth {
+ w = w * w * (3.0 - 2.0 * w);
+ }
+ return k1.1 * (1.0 - w) + k2.1 * w;
+ }
+ }
+ first.1
+}
+
+/// Install a custom bevel/carve profile from ramp keys (`(pos, value)`, both
+/// 0..1, sorted by pos). Sampled into the slope LUT the shader's `carve_slope`
+/// reads in place of its analytic smoothstep — every recess/boss/ridge wall in
+/// this process restyles on the next frame. Empty or single-key lists clear
+/// back to the analytic profile ([`clear_bevel_profile`]).
+pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
+ if keys.len() < 2 {
+ clear_bevel_profile();
+ return;
+ }
+ let n = BEVEL_PROFILE_SAMPLES;
+ let mut slopes = [0.0f32; BEVEL_PROFILE_SAMPLES];
+ for (i, slot) in slopes.iter_mut().enumerate() {
+ let h0 = sample_ramp_keys(keys, smooth, i as f32 / n as f32);
+ let h1 = sample_ramp_keys(keys, smooth, (i + 1) as f32 / n as f32);
+ *slot = (h1 - h0) * n as f32;
+ }
+ *BEVEL_PROFILE.write().unwrap() = Some(slopes);
+ BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+}
+
+/// Drop the custom bevel profile — walls return to the analytic smoothstep.
+pub fn clear_bevel_profile() {
+ let mut guard = BEVEL_PROFILE.write().unwrap();
+ if guard.is_some() {
+ *guard = None;
+ BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+ }
+}
+
+/// The installed profile's slope LUT, if any — what the renderer uploads.
+pub fn bevel_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
+ *BEVEL_PROFILE.read().unwrap()
+}
+
+/// Change counter for [`bevel_profile_slopes`] — a renderer re-uploads when it
+/// differs from the generation it last wrote.
+pub fn bevel_profile_generation() -> u64 {
+ BEVEL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
+}
+
/// Padding between the window plate's edge and the objects sitting on it, in
/// logical px (`style.surface.backplate.padding` in config.kdl). DE-wide so
/// every app's content sits the same distance off the plate rim.
@@ -5626,5 +5711,32 @@ mod tests {
assert!((w3.w - 101.05).abs() < 0.01);
assert!((w3.h - 82.857).abs() < 0.01);
}
+
+ #[test]
+ fn bevel_profile_lut_integrates_to_the_curves_net_rise() {
+ // The identity 0→1 curve: slopes sum/N to its net rise of 1 (the same
+ // total step the analytic smoothstep carries).
+ crate::layout::set_bevel_profile_keys(&[(0.0, 0.0), (1.0, 1.0)], true);
+ let slopes = crate::layout::bevel_profile_slopes().expect("profile installed");
+ let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ let rise: f32 = slopes.iter().map(|s| s / n).sum();
+ assert!((rise - 1.0).abs() < 0.001, "net rise {rise}");
+
+ // A rim curve that returns to its start height nets zero.
+ crate::layout::set_bevel_profile_keys(
+ &[(0.0, 0.5), (0.2, 1.0), (0.8, 1.0), (1.0, 0.5)],
+ false,
+ );
+ let slopes = crate::layout::bevel_profile_slopes().unwrap();
+ let rise: f32 = slopes.iter().map(|s| s / n).sum();
+ assert!(rise.abs() < 0.001, "net rise {rise}");
+
+ // Degenerate key lists clear back to the analytic profile; the
+ // generation moves on every change so renderers re-upload.
+ let gen = crate::layout::bevel_profile_generation();
+ crate::layout::set_bevel_profile_keys(&[(0.0, 1.0)], false);
+ assert!(crate::layout::bevel_profile_slopes().is_none());
+ assert!(crate::layout::bevel_profile_generation() > gen);
+ }
}
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index f3680be..511959c 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -85,6 +85,9 @@ const FRAMES_IN_FLIGHT: usize = 2;
/// size per frame in flight.
pub const MAX_PLATE_FEATURES: usize = 64;
const PLATE_FEATURE_BYTES: usize = 48;
+/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
+/// [8 vec4 of profile slope samples].
+const WINDOW_INFO_BYTES: vk::DeviceSize = 160;
pub(crate) struct AllocatedBuffer {
pub(crate) buffer: vk::Buffer,
@@ -192,6 +195,9 @@ pub struct VkRenderer {
descriptor_set: vk::DescriptorSet,
backdrop_sampler: vk::Sampler,
window_info: AllocatedBuffer,
+ /// The bevel-profile generation `window_info` was last written with —
+ /// `draw_frame_2d` rewrites the UBO when the layout global moves on.
+ profile_gen: u64,
plate_features: AllocatedBuffer,
frames: Vec<Frame>,
@@ -638,7 +644,7 @@ impl VkRenderer {
let window_info = create_cpu_buffer(
&device,
allocator,
- 16,
+ WINDOW_INFO_BYTES,
vk::BufferUsageFlags::UNIFORM_BUFFER,
"window-info",
);
@@ -686,7 +692,7 @@ impl VkRenderer {
let buffer_infos = [vk::DescriptorBufferInfo::default()
.buffer(window_info.buffer)
.offset(0)
- .range(16)];
+ .range(WINDOW_INFO_BYTES)];
let feature_infos = [vk::DescriptorBufferInfo::default()
.buffer(plate_features.buffer)
.offset(0)
@@ -776,6 +782,7 @@ impl VkRenderer {
descriptor_set,
backdrop_sampler,
window_info,
+ profile_gen: 0,
plate_features,
frames,
frame_index: 0,
@@ -810,14 +817,21 @@ impl VkRenderer {
}
fn write_window_info(&mut self) {
- let data = [
- self.extent.width as f32,
- self.extent.height as f32,
- self.clip_corner_radius(),
- crate::layout::corner_shape(),
- ];
+ // [size/clip vec4][profile meta vec4][8 vec4 of profile slope samples]
+ // — must stay in lockstep with shader2d's WindowInfo.
+ let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
+ data[0] = self.extent.width as f32;
+ data[1] = self.extent.height as f32;
+ data[2] = self.clip_corner_radius();
+ data[3] = crate::layout::corner_shape();
+ if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+ data[4] = 1.0;
+ data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[8..8 + slopes.len()].copy_from_slice(&slopes);
+ }
+ self.profile_gen = crate::layout::bevel_profile_generation();
if let Some(allocation) = self.window_info.allocation.as_mut() {
- allocation.mapped_slice_mut().unwrap()[..16]
+ allocation.mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
.copy_from_slice(bytemuck::cast_slice(&data));
}
}
@@ -1178,6 +1192,13 @@ impl VkRenderer {
self.core.device.reset_fences(&[in_flight]).unwrap();
+ // Re-upload the bevel-profile LUT when it changed (a live ramp
+ // edit). The other in-flight frame may still read the old bytes —
+ // both are valid profiles, so the one-frame mix is benign.
+ if self.profile_gen != crate::layout::bevel_profile_generation() {
+ self.write_window_info();
+ }
+
// 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() {
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index d7aad3d..fefe832 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -14,6 +14,13 @@ struct WindowInfo {
// Corner-shape exponent shared with the plates and the rounded-rect clip:
// circular arc at 2, superellipse squircle above.
corner_shape: f32,
+ // Custom bevel/carve profile (cce_ui::layout::set_bevel_profile_keys):
+ // x nonzero enables it, y = live sample count in `profile`.
+ profile_meta: vec4f,
+ // Slope samples of the profile's height curve h(v) (v 0 = plateau, 1 =
+ // carve floor / boss crest), sample i at v = (i + 0.5) / count, packed 4
+ // per vec4. carve_slope reads these in place of its analytic smoothstep.
+ profile: array<vec4f, 8>,
}
@group(0) @binding(2) var<uniform> window_info: WindowInfo;
@@ -193,11 +200,25 @@ fn roll_slope(f: f32) -> f32 {
}
// 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.
+// the carve floor) at v in [0, 1] across the wall. With a custom profile
+// installed (window_info.profile_meta.x), the slope comes from the uploaded
+// ramp LUT — it may go negative (non-monotonic curves: rims, ogees) and its
+// integral is the curve's net rise, not necessarily 1. Otherwise the analytic
+// default: 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 (window_info.profile_meta.x > 0.5) {
+ let n = window_info.profile_meta.y;
+ // Samples sit at v = (i + 0.5) / n; lerp between the two neighbors.
+ let x = clamp(clamp(v, 0.0, 1.0) * n - 0.5, 0.0, n - 1.0);
+ let i0 = u32(floor(x));
+ let i1 = min(i0 + 1u, u32(n) - 1u);
+ let fr = x - floor(x);
+ let s0 = window_info.profile[i0 >> 2u][i0 & 3u];
+ let s1 = window_info.profile[i1 >> 2u][i1 & 3u];
+ return mix(s0, s1, fr);
+ }
if (rrect_clip.rect1.w > 2.001) {
let w = v * (1.0 - v);
return 30.0 * w * w;
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 51c2720..37b1cd8 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -25,7 +25,7 @@ use crate::colors;
use crate::scene::layout::Rect;
use crate::scene::paint::PaintCtx;
use crate::widget::display::{Float3, TextLabel};
-use crate::widget::input::{Button, ColorSelector, Dropdown, Slider, Spinbox, TextBox, Toggle};
+use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
use crate::widget::{
Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
@@ -46,6 +46,10 @@ pub struct ParametersBg {
pub texts: Vec<Option<Adapted<TextBox>>>,
pub toggles: Vec<Option<Adapted<Toggle>>>,
pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
+ /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
+ /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
+ /// views can't carry the curve/key geometry.
+ pub ramps: Vec<Option<Adapted<Ramp>>>,
/// Titles of the sections the user has collapsed by clicking their header. Keyed by
/// title so it outlives the row rebuild `set_display_params` runs on every node change.
collapsed: std::collections::HashSet<String>,
@@ -129,6 +133,7 @@ impl ParametersBg {
texts: Vec::new(),
toggles: Vec::new(),
colors: Vec::new(),
+ ramps: Vec::new(),
collapsed: std::collections::HashSet::new(),
visible: true,
scroll_y: 0.0,
@@ -159,6 +164,9 @@ impl ParametersBg {
content_h.max(200.0)
} else if p.2 == "section" {
24.0
+ } else if p.2 == "ramp" {
+ // Label band + the ramp's graph and control strip.
+ 190.0
} else if p.2.starts_with("float3") {
108.0
} else if p.2.starts_with("slider") {
@@ -580,6 +588,14 @@ impl ParametersBg {
c.set_rect(r.0, r.1, r.2, r.3);
}
}
+ for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
+ if let Some(rp) = rp_opt {
+ let r = rects[i];
+ // Below the 18px label band own_text_labels draws (the ramp
+ // carries no label of its own).
+ rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
+ }
+ }
}
/// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
@@ -680,6 +696,16 @@ impl ParametersBg {
if let Some(c) = &self.colors[i] {
labels.extend(c.own_text_labels());
}
+ } else if ptype == "ramp" {
+ // The name label only — the ramp's own control labels ride its
+ // scene-path paint (paint_scene_rows).
+ labels.push(TextLabel {
+ text: name.clone(),
+ x: r.0,
+ y: r.1,
+ font_size: 12.0,
+ color: [0xaa, 0xaa, 0xbb],
+ });
} else {
labels.push(TextLabel {
text: format!("{}: {}", name, value),
@@ -695,6 +721,7 @@ impl ParametersBg {
/// The dropdown rows' popover, if one is open — the widget's OWN popover surface
/// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
+ /// The ramp rows' field dropdowns count too.
fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
for d_opt in &self.choices {
if let Some(d) = d_opt {
@@ -703,6 +730,18 @@ impl ParametersBg {
}
}
}
+ for rp_opt in &self.ramps {
+ if let Some(rp) = rp_opt {
+ let ramp = rp.inner();
+ if let Some(r) = ramp
+ .preset_dropdown
+ .popover_rect()
+ .or_else(|| ramp.line_type_dropdown.popover_rect())
+ {
+ return Some(r);
+ }
+ }
+ }
None
}
@@ -1099,6 +1138,27 @@ impl ParametersBg {
out
}
+ /// The scene-path companion to the legacy views: rows whose widgets paint
+ /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
+ /// circles, and field controls). A host rendering this panel through the
+ /// legacy hatches calls this with its own `PaintCtx` inside the pane's
+ /// scroll clip, after the flat chrome — or the rows draw as bare labels.
+ pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
+ if !self.visible {
+ return;
+ }
+ let hidden = self.hidden_rows();
+ let dummy = UiContext::new();
+ for (i, p) in self.display_params.iter().enumerate() {
+ if hidden[i] || p.2 != "ramp" {
+ continue;
+ }
+ if let Some(rp) = &self.ramps[i] {
+ rp.paint_self(&dummy, pc);
+ }
+ }
+ }
+
}
impl Layout for ParametersBg {
@@ -1152,6 +1212,7 @@ impl Paint for ParametersBg {
for (qx, qy, qw, qh, qc) in self.plain_quads() {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}
+ self.paint_scene_rows(ctx);
// Scene-path hosts get the scrollbar on top (the designer instead straddles it around
// the pane plate through `scrollbar_quads`).
for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
@@ -1225,6 +1286,12 @@ impl Paint for ParametersBg {
d.render_popover(pc);
}
}
+ for rp_opt in &self.ramps {
+ if let Some(rp) = rp_opt {
+ rp.inner().preset_dropdown.render_popover(pc);
+ rp.inner().line_type_dropdown.render_popover(pc);
+ }
+ }
}
}
@@ -1387,6 +1454,17 @@ impl Input for ParametersBg {
}
}
}
+ // Ramp rows tick their field widgets (preset application, slider→key
+ // sync) and drain their change flag — fold the curve back into the row
+ // value when it moved.
+ for i in 0..self.ramps.len() {
+ if let Some(rp) = &mut self.ramps[i] {
+ if rp.tick(dt, &mut dummy) {
+ self.display_params[i].1 = rp.inner().spec_string();
+ changed = true;
+ }
+ }
+ }
// Decay the "recently scrolled" window; keep frames coming until it expires so the
// scrollbar's sink behind the plate actually renders.
if self.scroll_activity > 0.0 {
@@ -1504,6 +1582,16 @@ impl Input for ParametersBg {
}
}
}
+ // Ramp rows: a move can drag a key — re-serialize the curve
+ // into the row value so hosts polling `node_params` see it.
+ for i in 0..self.ramps.len() {
+ if let Some(rp) = &mut self.ramps[i] {
+ if rp.on_cursor_moved(px, py, ui) {
+ self.display_params[i].1 = rp.inner().spec_string();
+ changed = true;
+ }
+ }
+ }
changed
}
@@ -1606,6 +1694,23 @@ impl Input for ParametersBg {
}
}
}
+ // The ramp rows' field dropdowns can pop over neighboring rows too.
+ for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
+ if hidden[i] {
+ continue;
+ }
+ if let Some(rp) = rp_opt {
+ let ramp = rp.inner();
+ if ramp.preset_dropdown.popover_rect().is_some()
+ || ramp.line_type_dropdown.popover_rect().is_some()
+ {
+ if rp.mouse_input(button, state, px, py, ui) {
+ self.display_params[i].1 = rp.inner().spec_string();
+ return true;
+ }
+ }
+ }
+ }
// 2. Propagate to our widgets
for (i, p) in self.display_params.iter_mut().enumerate() {
@@ -1691,6 +1796,13 @@ impl Input for ParametersBg {
return true;
}
}
+ } else if p.2 == "ramp" {
+ if let Some(rp) = &mut self.ramps[i] {
+ if rp.mouse_input(button, state, px, py, ui) {
+ p.1 = rp.inner().spec_string();
+ return true;
+ }
+ }
}
}
@@ -2240,6 +2352,15 @@ impl ParamController for ParametersBg {
None
}
}).collect();
+ self.ramps = self.display_params.iter().map(|p| {
+ if p.2 == "ramp" {
+ let mut rp = Ramp::new();
+ rp.inner_mut().set_spec(&p.1);
+ Some(rp)
+ } else {
+ None
+ }
+ }).collect();
} else {
for (i, p_new) in params.iter().enumerate() {
if Some(i) != self.focused_param && Some(i) != self.dragging_param {
@@ -2286,6 +2407,10 @@ impl ParamController for ParametersBg {
if !c.editing {
c.set_value_string(&p_new.1);
}
+ } else if let Some(ref mut rp) = self.ramps[i] {
+ if !rp.inner().is_dragging_key {
+ rp.set_value_string(&p_new.1);
+ }
}
}
}
@@ -2387,6 +2512,24 @@ mod tests {
);
}
+ #[test]
+ fn ramp_row_builds_from_spec_and_edits_serialize_back() {
+ let mut ctx = UiContext::new();
+ let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
+ let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
+ assert_eq!(rp.inner().keys.len(), 2);
+ assert!(rp.inner().smooth());
+
+ // A press inside the curve area adds a key, and the row value carries
+ // the re-serialized spec (what hosts poll and persist).
+ let (rx, ry, rw, _) = rp.rect();
+ p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
+ assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
+ let val = &ParamController::node_params(&*p)[0].1;
+ assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
+ assert!(val.starts_with("smooth;"));
+ }
+
#[test]
fn toggle_click_commits_value_and_unfocus_commits_editor() {
let mut ctx = UiContext::new();
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 0c7115e..7aa654e 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -22,7 +22,7 @@ pub use trackpad::{Trackpad, Finger};
pub use font_selector::FontSelector;
pub use button_strip::ButtonStrip;
pub use keybind_recorder::KeybindRecorder;
-pub use ramp::{Ramp, RampKey, ColorRamp, ColorRampKey};
+pub use ramp::{Ramp, RampKey, ColorRamp, ColorRampKey, format_ramp_spec, parse_ramp_spec};
pub const BREADCRUMB_PADDING: f32 = 8.0;
pub const SEGMENT_GAP: f32 = 4.0;
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index fecbf42..39d8644 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -241,7 +241,7 @@ impl Ramp {
}
self.keys[0].value
}
-
+
fn sort_keys(&mut self) {
let prev_selected_id = self.selected_key_idx.map(|idx| self.keys[idx].pos);
self.keys.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap());
@@ -251,6 +251,73 @@ impl Ramp {
}
}
}
+
+ /// Whether segments blend with smoothstep (the Bezier line type) vs linearly.
+ pub fn smooth(&self) -> bool {
+ self.line_type_dropdown.selected == 1
+ }
+
+ /// This ramp's state as the DE's ramp spec string ([`format_ramp_spec`]).
+ pub fn spec_string(&self) -> String {
+ let keys: Vec<(f32, f32)> = self.keys.iter().map(|k| (k.pos, k.value)).collect();
+ format_ramp_spec(&keys, self.smooth())
+ }
+
+ /// Apply a spec string ([`parse_ramp_spec`]); returns whether anything changed.
+ /// Unparsable specs are ignored (keeps the current curve).
+ pub fn set_spec(&mut self, spec: &str) -> bool {
+ let Some((keys, smooth)) = parse_ramp_spec(spec) else {
+ return false;
+ };
+ let new_keys: Vec<RampKey> =
+ keys.into_iter().map(|(pos, value)| RampKey { pos, value }).collect();
+ let new_line = if smooth { 1 } else { 0 };
+ let changed = self.line_type_dropdown.selected != new_line
+ || self.keys.len() != new_keys.len()
+ || self
+ .keys
+ .iter()
+ .zip(new_keys.iter())
+ .any(|(a, b)| (a.pos - b.pos).abs() > 0.0005 || (a.value - b.value).abs() > 0.0005);
+ if changed {
+ self.keys = new_keys;
+ self.line_type_dropdown.selected = new_line;
+ self.selected_key_idx = None;
+ self.preset_dropdown.selected = 0; // Custom
+ self.arrange_fields();
+ }
+ changed
+ }
+}
+
+/// Serialize ramp keys + line type as the DE's ramp spec string:
+/// `"smooth;0.000:0.500,0.200:1.000,…"` (`"linear;…"` for straight segments) —
+/// the format ramp-valued params travel in (`ParametersBg` "ramp" rows,
+/// project files, `cce_ui::layout::set_bevel_profile_keys` consumers).
+pub fn format_ramp_spec(keys: &[(f32, f32)], smooth: bool) -> String {
+ let body: Vec<String> =
+ keys.iter().map(|(p, v)| format!("{:.3}:{:.3}", p, v)).collect();
+ format!("{};{}", if smooth { "smooth" } else { "linear" }, body.join(","))
+}
+
+/// Parse a ramp spec string ([`format_ramp_spec`]) into `(keys, smooth)`.
+/// `None` for anything that doesn't yield at least two keys.
+pub fn parse_ramp_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
+ let (head, body) = spec.split_once(';')?;
+ let smooth = head.trim() == "smooth";
+ let mut keys = Vec::new();
+ for part in body.split(',') {
+ let (p, v) = part.split_once(':')?;
+ keys.push((
+ p.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+ v.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+ ));
+ }
+ if keys.len() < 2 {
+ return None;
+ }
+ keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
+ Some((keys, smooth))
}
@@ -947,6 +1014,16 @@ impl Input for Ramp {
true
}
+ /// The curve as a ramp spec string ([`format_ramp_spec`]) — the value hosts
+ /// poll and persist for ramp-valued params.
+ fn value_string(&self) -> Option<String> {
+ Some(self.spec_string())
+ }
+
+ fn set_value_string(&mut self, val: &str) -> bool {
+ self.set_spec(val)
+ }
+
/// The open dropdown popover extends the hit area (the 5p Dropdown pattern).
fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
if let Some((px, py, pw, ph)) = {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 6958799..1241ace 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -519,7 +519,8 @@ 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,
- ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey
+ ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
+ format_ramp_spec, parse_ramp_spec
};
pub use self::container::{
ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,