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

commitff5efc4f855dd2a497d28fa5246a1a5fe8fdf573
parent7c13032f44
authorLucas Galante <[email protected]>
date2026-08-20 10:21
the (ramp) config value type: RampPreview widget + cce-ramp --key editing

A ramp-spec string annotated (ramp) is now a first-class config value,
the sibling of (relief):

- RampPreview (widget/input/ramp_preview.rs): the spec's value curve as
  a lit polyline in a dark well — BevelPreview's shape and click
  contract, for hosts' inline key rows.
- cce-ramp gains --key <dotted.key> / --config <path>: the curve seeds
  from that key's spec, a Save/Cancel row appears under the plot (Save
  writes the spec back to the single key with the (ramp) annotation;
  Cancel closes without saving), and the title names the key. Without
  --key the stdout scratchpad is unchanged.

Verified in a shadow session: seeded from overview_ramp, Save wrote
overview_ramp=(ramp)"smooth;..." byte-identical, Cancel exits.

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

 src/bin/cce-ramp.rs              | 137 ++++++++++++++++++++++++++++--
 src/widget/input/mod.rs          |   2 +
 src/widget/input/ramp_preview.rs | 174 +++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs                |   2 +-
 4 files changed, 306 insertions(+), 9 deletions(-)

diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index 935f359..c5ae0dd 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -3,6 +3,13 @@
 //! `make install` puts it on PATH; run it inside a Wayland session. Edits print
 //! their ramp spec to stdout, so the popup doubles as a curve scratchpad.
 //!
+//! `--key <dotted.key>` (with optional `--config <path>`, default the shared
+//! config.kdl) turns the scratchpad into the `(ramp)` VALUE editor: the curve
+//! seeds from that key's ramp spec, Save writes the spec back to that one key
+//! with the `(ramp)` annotation, and Cancel closes without saving — the
+//! `cce-relief --key` convention. cce-data-editor spawns it this way from a
+//! ramp value's inline preview.
+//!
 //! Architecture mirrors the reference `DemoApp` (`src/main.rs`): display-list
 //! frame, routed events, in-frame popovers.
 
@@ -10,7 +17,8 @@ use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, Win
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Ramp, WidgetHost,
+    Adapted, Button, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Ramp,
+    WidgetHost,
 };
 use wayland_client::QueueHandle;
 
@@ -28,6 +36,18 @@ struct RampPopup {
     ramp: Adapted<Ramp>,
     /// Last spec printed to stdout — edits log their curve for copy/paste.
     last_spec: String,
+    /// `--key` mode only; parked off-screen in the scratchpad.
+    save_button: Adapted<Button>,
+    cancel_button: Adapted<Button>,
+    /// `--key <dotted.key>`: Save writes the spec as a `(ramp)` value at
+    /// this key; the curve seeds from it. None = the stdout scratchpad.
+    target_key: Option<String>,
+    config_path: std::path::PathBuf,
+    /// Status line under the buttons (key mode): what the last save did.
+    status: String,
+    /// Set by the cancel click in `drain_changes` (no exit access there);
+    /// `handle_mouse_input` turns it into `RampMsg::Exit`.
+    exit_requested: bool,
     ui_context: cce_ui::context::UiContext,
     width: u32,
     height: u32,
@@ -44,6 +64,28 @@ impl RampPopup {
             self.last_spec = spec;
             self.needs_rebuild = true;
         }
+        if self.save_button.take_click() {
+            self.save_to_key();
+            self.needs_rebuild = true;
+        }
+        if self.cancel_button.take_click() {
+            // Discard-and-close: nothing persisted without Save.
+            self.exit_requested = true;
+        }
+    }
+
+    /// Persist the current curve as a `(ramp)` value at the target key.
+    fn save_to_key(&mut self) {
+        let Some(key) = self.target_key.clone() else { return };
+        let p = self.config_path.to_string_lossy().into_owned();
+        let spec = self.ramp.inner().spec_string();
+        let ok = cce_ui::config::write_config_value_typed(&p, &key, &spec, "style", Some("ramp"));
+        self.status = if ok {
+            println!("saved {key} -> {p}");
+            format!("Saved — {key} holds this curve.")
+        } else {
+            "Save FAILED — see config permissions.".to_string()
+        };
     }
 }
 
@@ -55,11 +97,48 @@ impl Application for RampPopup {
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
-        let ramp = Ramp::new();
+        let mut ramp = Ramp::new();
+
+        // `--key <dotted.key>` / `--config <path>`: edit one `(ramp)` value
+        // in place — seed the curve from it, Save writes it back.
+        let mut config_path = cce_ui::config::get_config_path();
+        let mut target_key: Option<String> = None;
+        let args: Vec<String> = std::env::args().collect();
+        let mut i = 1;
+        while i < args.len() {
+            if args[i] == "--config" && i + 1 < args.len() {
+                config_path = std::path::PathBuf::from(&args[i + 1]);
+                i += 1;
+            } else if args[i] == "--key" && i + 1 < args.len() {
+                target_key = Some(args[i + 1].clone());
+                i += 1;
+            }
+            i += 1;
+        }
+        if let Some(key) = &target_key {
+            let seed = std::fs::read_to_string(&config_path)
+                .ok()
+                .map(|c| cce_ui::config::parse_kdl_to_json(&c))
+                .and_then(|v| v.pointer(&format!("/{}", key.replace('.', "/"))).cloned())
+                .and_then(|v| v.as_str().map(String::from));
+            if let Some(spec) = seed {
+                ramp.inner_mut().set_spec(&spec);
+            }
+        }
+
         let last_spec = ramp.inner().spec_string();
         Self {
             ramp,
             last_spec,
+            save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
+            cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
+            status: match &target_key {
+                Some(k) => format!("Edits are live in the curve; Save writes the {k} key."),
+                None => String::new(),
+            },
+            target_key,
+            config_path,
+            exit_requested: false,
             ui_context: cce_ui::context::UiContext::new(),
             width: 540,
             height: 420,
@@ -77,13 +156,22 @@ impl Application for RampPopup {
     }
 
     fn settings(&self) -> WindowSettings {
+        let title = match &self.target_key {
+            Some(k) => {
+                let parts: Vec<&str> = k.split('.').collect();
+                format!("Ramp — {}", parts[parts.len().saturating_sub(2)..].join("."))
+            }
+            None => "Ramp".to_string(),
+        };
+        // The key mode adds a Save/Cancel row under the curve.
+        let extra = if self.target_key.is_some() { 56 } else { 0 };
         WindowSettings {
-            title: "Ramp".to_string(),
+            title,
             app_id: "cce-ramp".to_string(),
             width: 460,
-            height: 340,
+            height: 340 + extra,
             fullscreen: false,
-            min_size: Some((420, 300)),
+            min_size: Some((420, 300 + extra)),
         }
     }
 
@@ -108,6 +196,8 @@ impl Application for RampPopup {
             let w = self.ramp.as_ptr_mut();
             let id = self.ramp.id();
             self.ui_context.register_widget(id, w);
+            self.ui_context.register_widget(self.save_button.id(), self.save_button.as_ptr_mut());
+            self.ui_context.register_widget(self.cancel_button.id(), self.cancel_button.as_ptr_mut());
         }
 
         let size_changed = self.width != size.width as u32
@@ -122,14 +212,24 @@ impl Application for RampPopup {
             // One widget, one rect: the ramp fills the plate inside half the
             // DE pad (this popup runs tighter than a full client). The plate
             // itself is inset by OVERFLOW_MARGIN so key pegs can render past
-            // the window frame into the transparent surface rim.
+            // the window frame into the transparent surface rim. Key mode
+            // reserves a Save/Cancel band under the curve.
             let pad = OVERFLOW_MARGIN + cce_ui::layout::backplate_padding() / 2.0;
+            let band = if self.target_key.is_some() { 56.0 } else { 0.0 };
             self.ramp.set_rect(
                 pad,
                 pad,
                 (self.width as f32 - 2.0 * pad).max(0.0),
-                (self.height as f32 - 2.0 * pad).max(0.0),
+                (self.height as f32 - 2.0 * pad - band).max(0.0),
             );
+            if self.target_key.is_some() {
+                let by = self.height as f32 - pad - 30.0;
+                self.save_button.set_rect(pad, by, 96.0, 28.0);
+                self.cancel_button.set_rect(pad + 96.0 + 12.0, by, 96.0, 28.0);
+            } else {
+                self.save_button.set_rect(-1000.0, -1000.0, 1.0, 1.0);
+                self.cancel_button.set_rect(-1000.0, -1000.0, 1.0, 1.0);
+            }
 
             self.needs_rebuild = false;
             self.ui_context.rebuild_spatial_grid();
@@ -163,6 +263,22 @@ impl Application for RampPopup {
         );
 
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ramp, &mut pc);
+        if self.target_key.is_some() {
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.save_button, &mut pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.cancel_button, &mut pc);
+            if !self.status.is_empty() {
+                let pad = OVERFLOW_MARGIN + cce_ui::layout::backplate_padding() / 2.0;
+                pc.text_with(
+                    self.status.clone(),
+                    pad + 2.0 * (96.0 + 12.0),
+                    self.height as f32 - pad - 24.0,
+                    12.0,
+                    [0x9a, 0x9a, 0xa4],
+                    None,
+                    None,
+                );
+            }
+        }
 
         // The ramp's field-dropdown popover, drawn into the frame on top.
         if let Some((px, py, pw, ph)) = self.ramp.popover_rect() {
@@ -257,8 +373,13 @@ impl Application for RampPopup {
             local_x: pos.x,
             local_y: pos.y,
         };
-        let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
+        let mut changed = self.ui_context.propagate_event(&ev, self.ramp.id());
+        changed |= self.ui_context.propagate_event(&ev, self.save_button.id());
+        changed |= self.ui_context.propagate_event(&ev, self.cancel_button.id());
         self.drain_changes();
+        if self.exit_requested {
+            return Some(RampMsg::Exit);
+        }
         if changed || self.needs_rebuild {
             *needs_rebuild = true;
             self.needs_rebuild = true;
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index f8d2088..b3d315a 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -12,6 +12,7 @@ pub mod button_strip;
 pub mod keybind_recorder;
 pub mod ramp;
 pub mod bevel_preview;
+pub mod ramp_preview;
 
 pub use button::{Button, ButtonKind, PageButton};
 pub use checkbox::{Checkbox, Toggle};
@@ -27,6 +28,7 @@ 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 use ramp_preview::RampPreview;
 
 // `BREADCRUMB_PADDING` / `SEGMENT_GAP` lived here and had exactly one consumer
 // between them. The breadcrumb owns its own spacing now (`Breadcrumb::SEG_INSET`,
diff --git a/src/widget/input/ramp_preview.rs b/src/widget/input/ramp_preview.rs
new file mode 100644
index 0000000..482e6f6
--- /dev/null
+++ b/src/widget/input/ramp_preview.rs
@@ -0,0 +1,174 @@
+//! `RampPreview` — the `(ramp)` config type's inline preview: the spec's
+//! value curve drawn as a lit polyline in a dark well. Read-only, the
+//! sibling of [`super::bevel_preview::BevelPreview`]: it exists to SHOW the
+//! curve and take a click, which hosts (cce-data-editor) answer by opening
+//! the full `cce-ramp` editor on the key. The spec format is the DE-wide
+//! ramp string (`"smooth;0.000:0.150,0.400:1.000,…"` — see
+//! [`super::ramp::parse_ramp_spec`]).
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{Cap, PaintCtx};
+use crate::widget::{Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
+
+pub struct RampPreview {
+    /// Parsed spec: sorted `(pos, value)` keys + smooth/linear blending.
+    keys: Vec<(f32, f32)>,
+    smooth: bool,
+    just_clicked: bool,
+    hovered: bool,
+}
+
+impl RampPreview {
+    pub fn new() -> Adapted<RampPreview> {
+        Adapted::new(RampPreview {
+            keys: vec![(0.0, 0.0), (1.0, 1.0)],
+            smooth: false,
+            just_clicked: false,
+            hovered: false,
+        })
+    }
+
+    /// Set the previewed curve from a ramp spec string; anything unparsable
+    /// falls back to the linear identity.
+    pub fn set_spec_str(&mut self, s: &str) {
+        match crate::widget::parse_ramp_spec(s) {
+            Some((keys, smooth)) => {
+                self.keys = keys;
+                self.smooth = smooth;
+            }
+            None => {
+                self.keys = vec![(0.0, 0.0), (1.0, 1.0)];
+                self.smooth = false;
+            }
+        }
+    }
+
+    /// The curve's value at `t` — endpoint-clamped, per-segment linear or
+    /// smoothstep, mirroring `Ramp::get_interpolated_value` and the policy
+    /// crate's `ramp_value`.
+    fn value_at(&self, t: f32) -> f32 {
+        let keys = &self.keys;
+        if keys.is_empty() {
+            return 0.0;
+        }
+        if t <= keys[0].0 {
+            return keys[0].1;
+        }
+        if t >= keys[keys.len() - 1].0 {
+            return keys[keys.len() - 1].1;
+        }
+        for pair in keys.windows(2) {
+            let (p1, v1) = pair[0];
+            let (p2, v2) = pair[1];
+            if t >= p1 && t <= p2 {
+                let range = p2 - p1;
+                if range.abs() < 1e-4 {
+                    return v1;
+                }
+                let mut w = (t - p1) / range;
+                if self.smooth {
+                    w = w * w * (3.0 - 2.0 * w);
+                }
+                return v1 * (1.0 - w) + v2 * w;
+            }
+        }
+        keys[keys.len() - 1].1
+    }
+}
+
+impl Layout for RampPreview {}
+
+impl Paint for RampPreview {
+    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);
+
+        let m = 4.0f32;
+        let x_l = rect.x + m;
+        let x_r = rect.x + rect.width - m;
+        let y_hi = rect.y + m;
+        let y_lo = rect.y + rect.height - m;
+        if x_r <= x_l || y_lo <= y_hi {
+            return;
+        }
+        let y_of = |v: f32| y_lo - v.clamp(0.0, 1.0) * (y_lo - y_hi);
+
+        // The curve, lit per segment under the DE light azimuth — the same
+        // treatment as BevelPreview's surface stroke, so the two types read
+        // as siblings in a key list.
+        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, y_of(self.value_at(0.0)));
+        for i in 1..=n_seg {
+            let t = i as f32 / n_seg as f32;
+            let x = x_l + (x_r - x_l) * t;
+            let y = y_of(self.value_at(t));
+            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 RampPreview {
+    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::*;
+
+    #[test]
+    fn spec_parse_and_interpolate() {
+        let mut p = RampPreview {
+            keys: vec![],
+            smooth: false,
+            just_clicked: false,
+            hovered: false,
+        };
+        p.set_spec_str("linear;0.0:0.0,0.5:1.0,1.0:0.0");
+        assert!((p.value_at(0.25) - 0.5).abs() < 1e-4);
+        assert!((p.value_at(0.5) - 1.0).abs() < 1e-4);
+        // Unparsable falls back to the identity, not to empty.
+        p.set_spec_str("garbage");
+        assert!((p.value_at(0.5) - 0.5).abs() < 1e-4);
+    }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 4f2ae22..b3c3540 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -541,7 +541,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,
+    BevelPreview, bevel_ease, parse_bevel_knobs, RampPreview,
     ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
     format_ramp_spec, parse_ramp_spec
 };