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