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

commitd104dbae8c9c9667ee3466ebc34a635b3a526efb
parent4a38c24b6e
authorLucas Galante <[email protected]>
date2026-07-23 14:25
feat: cce-ramp — a standalone popup hosting the Ramp widget

A dev workbench for iterating on the ramp's look in isolation: one floating
window, the Ramp filling the plate, in-frame popovers for its field
dropdowns, Escape/Ctrl+Q to quit. Every edit prints its ramp spec line to
stdout, so the popup doubles as a curve scratchpad (paste into a Bevel
Profile param or config). Mirrors the DemoApp reference architecture;
`make install` now installs it alongside the demo binary.

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

 Makefile            |   1 +
 src/bin/cce-ramp.rs | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 271 insertions(+)

diff --git a/Makefile b/Makefile
index 07420a4..2af2a4a 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,7 @@ build:
 install: build
 	mkdir -p ~/.local/bin
 	install -m 755 ../target/release/cce-ui ~/.local/bin/cce-ui
+	install -m 755 ../target/release/cce-ramp ~/.local/bin/cce-ramp
 
 run:
 	cargo run
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
new file mode 100644
index 0000000..4894409
--- /dev/null
+++ b/src/bin/cce-ramp.rs
@@ -0,0 +1,270 @@
+//! `cce-ramp` — a minimal popup hosting the [`Ramp`] widget in isolation, for
+//! iterating on the widget's look without driving a full client around it.
+//! `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.
+//!
+//! Architecture mirrors the reference `DemoApp` (`src/main.rs`): display-list
+//! frame, routed events, in-frame popovers.
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+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,
+};
+use wayland_client::QueueHandle;
+
+#[derive(Debug, Clone)]
+enum RampMsg {
+    Exit,
+}
+
+struct RampPopup {
+    ramp: Adapted<Ramp>,
+    /// Last spec printed to stdout — edits log their curve for copy/paste.
+    last_spec: String,
+    ui_context: cce_ui::context::UiContext,
+    width: u32,
+    height: u32,
+    scale_factor: f64,
+    needs_rebuild: bool,
+    registered: bool,
+}
+
+impl RampPopup {
+    fn drain_changes(&mut self) {
+        let spec = self.ramp.inner().spec_string();
+        if spec != self.last_spec {
+            println!("{spec}");
+            self.last_spec = spec;
+            self.needs_rebuild = true;
+        }
+    }
+}
+
+impl Application for RampPopup {
+    type Message = RampMsg;
+
+    fn new(
+        _qh: &QueueHandle<EngineState<Self>>,
+        _sender: calloop::channel::Sender<Self::Message>,
+    ) -> Self {
+        cce_ui::scale::set_scale_factor(1.0);
+        let ramp = Ramp::new();
+        let last_spec = ramp.inner().spec_string();
+        Self {
+            ramp,
+            last_spec,
+            ui_context: cce_ui::context::UiContext::new(),
+            width: 460,
+            height: 340,
+            scale_factor: 1.0,
+            needs_rebuild: true,
+            registered: false,
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Ramp".to_string(),
+            app_id: "cce-ramp".to_string(),
+            width: 460,
+            height: 340,
+            fullscreen: false,
+            min_size: Some((300, 220)),
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
+        match msg {
+            RampMsg::Exit => *exit = true,
+        }
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.ui_context.tick(dt) {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        if !self.registered {
+            self.registered = true;
+            let w = self.ramp.as_ptr_mut();
+            let id = self.ramp.id();
+            self.ui_context.register_widget(id, w);
+        }
+
+        let size_changed = self.width != size.width as u32
+            || self.height != size.height as u32
+            || self.scale_factor != scale;
+        if self.needs_rebuild || size_changed {
+            self.width = size.width as u32;
+            self.height = size.height as u32;
+            self.scale_factor = scale;
+            cce_ui::scale::set_scale_factor(scale as f32);
+
+            // One widget, one rect: the ramp fills the plate inside the DE pad.
+            let pad = cce_ui::layout::backplate_padding();
+            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.needs_rebuild = false;
+            self.ui_context.rebuild_spatial_grid();
+        }
+
+        self.ui_context.clear_popovers();
+        if self.ramp.popover_rect().is_some() {
+            self.ui_context.register_popover(&mut self.ramp);
+        }
+
+        let mut pc = PaintCtx::new();
+        let (w, h) = (self.width as f32, self.height as f32);
+
+        // The window plate (the DemoApp idiom): page-low color at the configured
+        // opacity, config corner radius, rolled perimeter.
+        let mut plate = cce_ui::color::page_low_color();
+        if plate[3] > 0.001 {
+            plate[3] = cce_ui::color::active_backplate_opacity();
+        }
+        let radius = cce_ui::colors::backplate_corner_radius();
+        let bevel = cce_ui::layout::bevel_width();
+        pc.plate(
+            Rect { x: 0.0, y: 0.0, width: w, height: h },
+            (radius, radius, radius, radius),
+            plate,
+            bevel,
+        );
+
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ramp, &mut pc);
+
+        // The ramp's field-dropdown popover, drawn into the frame on top.
+        if let Some((px, py, pw, ph)) = self.ramp.popover_rect() {
+            let mut coll = cce_ui::layout::PopoverCollector::new();
+            self.ramp.inner().preset_dropdown.render_popover(&mut coll);
+            self.ramp.inner().line_type_dropdown.render_popover(&mut coll);
+            for &(c, x, y, qw, qh) in &coll.rects {
+                pc.quad(Rect { x, y, width: qw, height: qh }, c);
+            }
+            let bounds = Some([px, py, px + pw, py + ph]);
+            for (content, size, tx, ty, color, font, _b) in coll.texts {
+                let color_u8 = [
+                    (color[0] * 255.0).clamp(0.0, 255.0) as u8,
+                    (color[1] * 255.0).clamp(0.0, 255.0) as u8,
+                    (color[2] * 255.0).clamp(0.0, 255.0) as u8,
+                ];
+                pc.text_with(content, tx, ty, size, color_u8, font, bounds);
+            }
+        }
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+        Some(&self.ui_context)
+    }
+
+    fn is_movable_backplate_at(&self, px: f32, py: f32) -> bool {
+        self.ui_context.drag_allowed_at(px, py)
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+        let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
+        self.drain_changes();
+        if changed || self.needs_rebuild {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        let ev = Event::MouseButton {
+            button,
+            state,
+            x: pos.x,
+            y: pos.y,
+            local_x: pos.x,
+            local_y: pos.y,
+        };
+        let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
+        self.drain_changes();
+        if changed || self.needs_rebuild {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(
+        &mut self,
+        delta: &MouseScrollDelta,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) {
+        let ev = Event::MouseWheel {
+            delta: delta.clone(),
+            x: pos.x,
+            y: pos.y,
+            local_x: pos.x,
+            local_y: pos.y,
+        };
+        let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
+        self.drain_changes();
+        if changed || self.needs_rebuild {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn handle_key_input(
+        &mut self,
+        event: &KeyEvent,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        use cce_ui::widget::{Key, NamedKey};
+        if event.state == ElementState::Pressed {
+            if event.logical_key == Key::Named(NamedKey::Escape) {
+                return Some(RampMsg::Exit);
+            }
+            if event.ctrl {
+                if let Key::Character(ref c) = event.logical_key {
+                    if c == "q" {
+                        return Some(RampMsg::Exit);
+                    }
+                }
+            }
+        }
+        let ev = Event::KeyInput(event.clone());
+        let handled = self.ui_context.propagate_event(&ev, self.ramp.id());
+        self.drain_changes();
+        if handled || self.needs_rebuild {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+        None
+    }
+}
+
+fn main() {
+    cce_ui::engine::run::<RampPopup>();
+}