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

commit9137d8daecb83cb37a61980c9398537685debdd3
parente4844d846c
authorLucas Galante <[email protected]>
date2026-07-04 19:50
Implement new Houdini-style Ramp control element and styling keys

 src/color.rs             |  18 +++
 src/layout.rs            |  30 ++++
 src/widget/input/mod.rs  |   2 +
 src/widget/input/ramp.rs | 371 +++++++++++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs        |   2 +-
 5 files changed, 422 insertions(+), 1 deletion(-)

diff --git a/src/color.rs b/src/color.rs
index 8c257f7..62cff78 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -63,6 +63,8 @@ static BACKPLATE_STATUSBAR_COLOR: RwLock<[f32; 4]> = RwLock::new([0.06, 0.06, 0.
 static BACKPLATE_STATUSBAR_TEXT_COLOR: RwLock<[f32; 4]> = RwLock::new([0.6666, 0.6666, 0.7333, 1.0]);
 static BACKPLATE_STATUSBAR_BLUR: RwLock<bool> = RwLock::new(false);
 static BUTTON_BACKGROUND_COLOR: RwLock<[f32; 4]> = RwLock::new(BUTTON_IDLE);
+static RAMP_BACKGROUND_COLOR: RwLock<[f32; 4]> = RwLock::new([0.08, 0.08, 0.12, 1.0]);
+static RAMP_BORDER_COLOR: RwLock<[f32; 4]> = RwLock::new([0.18, 0.18, 0.24, 1.0]);
 
 static TREE_BACKGROUND_COLOR: RwLock<[f32; 4]> = RwLock::new([0.08, 0.08, 0.12, 0.3]);
 static TREE_BORDER_COLOR: RwLock<[f32; 4]> = RwLock::new([0.18, 0.18, 0.24, 1.0]);
@@ -317,6 +319,12 @@ fn parse_and_set_colors(content: &str) {
     if let Some(c) = get_color("/layout/toggle_bg_color") {
         if let Ok(mut lock) = TOGGLE_BG_COLOR.write() { *lock = c; }
     }
+    if let Some(c) = get_color("/style/control/ramp/background") {
+        if let Ok(mut lock) = RAMP_BACKGROUND_COLOR.write() { *lock = c; }
+    }
+    if let Some(c) = get_color("/style/control/ramp/border_color") {
+        if let Ok(mut lock) = RAMP_BORDER_COLOR.write() { *lock = c; }
+    }
     let parsed_list_bg = get_color("/layout/list_bg_color");
     let parsed_breadcrumb_bg = get_color("/layout/breadcrumb_bg_color");
     let parsed_popover_bg = get_color("/layout/popover_bg_color");
@@ -965,6 +973,16 @@ pub fn backplate_corner_radius() -> f32 {
     *BACKPLATE_CORNER_RADIUS.read().unwrap()
 }
 
+pub fn ramp_background_color() -> [f32; 4] {
+    load_colors_once();
+    *RAMP_BACKGROUND_COLOR.read().unwrap()
+}
+
+pub fn ramp_border_color() -> [f32; 4] {
+    load_colors_once();
+    *RAMP_BORDER_COLOR.read().unwrap()
+}
+
 pub fn set_backplate_corner_radius(radius: f32) {
     if let Ok(mut lock) = BACKPLATE_CORNER_RADIUS.write() {
         *lock = radius;
diff --git a/src/layout.rs b/src/layout.rs
index 7149f6c..f0f92b1 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -104,6 +104,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 "style.control.toggle.border_width" => "toggle_border_width",
                 "style.control.toggle.disabled_color" => "toggle_disabled_color",
                 "style.control.toggle.corner_radius" => "toggle_corner_radius",
+                "style.control.ramp.height" => "ramp_height",
                 "style.status.normal_color" => "status_normal_color",
                 "style.status.background_color" => "status_background_color",
                 "style.status.background_blur" => "status_background_blur",
@@ -244,6 +245,7 @@ static BREADCRUMB_FONT: RwLock<String> = RwLock::new(String::new());
 static PAGINATOR_TAB_PADDING_X: RwLock<f32> = RwLock::new(10.0);
 static BUTTON_PADDING: RwLock<f32> = RwLock::new(14.0);
 static BUTTON_HEIGHT: RwLock<f32> = RwLock::new(40.0);
+static RAMP_HEIGHT: RwLock<f32> = RwLock::new(32.0);
 static BUTTON_STRIP_SPACING: RwLock<f32> = RwLock::new(8.0);
 static SCROLLBAR_WIDTH: RwLock<f32> = RwLock::new(4.0);
 static TREE_OPACITY: RwLock<f32> = RwLock::new(1.0);
@@ -3102,6 +3104,34 @@ pub fn button_height() -> f32 {
     *BUTTON_HEIGHT.read().unwrap()
 }
 
+pub fn ramp_height() -> f32 {
+    use std::sync::Once;
+    static INIT: Once = Once::new();
+    INIT.call_once(|| {
+        if let Some(content) = read_config() {
+            for line in content.lines() {
+                let trimmed = line.trim();
+                if let Some(rest) = trimmed.strip_prefix("ramp_height") {
+                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+                    let val_str = rest.trim_end_matches('"').trim();
+                    if let Ok(val) = val_str.parse::<f32>() {
+                        if let Ok(mut lock) = RAMP_HEIGHT.write() {
+                            *lock = val;
+                        }
+                    }
+                }
+            }
+        }
+    });
+    *RAMP_HEIGHT.read().unwrap()
+}
+
+pub fn set_ramp_height(height: f32) {
+    if let Ok(mut lock) = RAMP_HEIGHT.write() {
+        *lock = height;
+    }
+}
+
 pub fn set_button_height(height: f32) {
     if let Ok(mut lock) = BUTTON_HEIGHT.write() {
         *lock = height;
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 1b33d63..1ad13fd 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -12,6 +12,7 @@ pub mod button_strip;
 pub mod multi_control;
 pub mod keybinds_control;
 pub mod keybind_recorder;
+pub mod ramp;
 
 pub use canvas::Canvas;
 pub use button::{Button, ButtonKind, PageButton};
@@ -27,6 +28,7 @@ pub use button_strip::ButtonStrip;
 pub use multi_control::{MultiControl, InstancedControl, InstancedWidget, MultiControlRow};
 pub use keybinds_control::{KeybindsControl, KeybindRow};
 pub use keybind_recorder::KeybindRecorder;
+pub use ramp::{Ramp, RampKey};
 
 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
new file mode 100644
index 0000000..19650c3
--- /dev/null
+++ b/src/widget/input/ramp.rs
@@ -0,0 +1,371 @@
+use crate::colors;
+use crate::widget::*;
+use crate::widget::input::{Slider, Button};
+
+#[derive(Debug, Clone)]
+pub struct RampKey {
+    pub pos: f32,
+    pub color: [f32; 3],
+}
+
+pub struct Ramp {
+    pub base: Widget,
+    pub keys: Vec<RampKey>,
+    pub selected_key_idx: Option<usize>,
+    pub is_dragging_key: bool,
+    
+    // Child controls for color editing & deletion
+    pub r_slider: Slider,
+    pub g_slider: Slider,
+    pub b_slider: Slider,
+    pub del_button: Button,
+    
+    pub parent: Option<*mut (dyn Element + 'static)>,
+}
+
+impl Ramp {
+    pub fn new() -> Self {
+        let keys = vec![
+            RampKey { pos: 0.0, color: [0.0, 0.0, 0.0] },
+            RampKey { pos: 1.0, color: [1.0, 1.0, 1.0] },
+        ];
+        
+        let r_slider = Slider::new().with_label("Red");
+        let g_slider = Slider::new().with_label("Green");
+        let b_slider = Slider::new().with_label("Blue");
+        let del_button = Button::new(0.0, 0.0, 70.0, 28.0).with_label("Delete Key");
+        
+        Self {
+            base: Widget::new(),
+            keys,
+            selected_key_idx: None,
+            is_dragging_key: false,
+            r_slider,
+            g_slider,
+            b_slider,
+            del_button,
+            parent: None,
+        }
+    }
+    
+    // Linear color interpolation helper
+    pub fn get_interpolated_color(&self, t: f32) -> [f32; 3] {
+        if self.keys.is_empty() {
+            return [0.0, 0.0, 0.0];
+        }
+        if t <= self.keys[0].pos {
+            return self.keys[0].color;
+        }
+        if t >= self.keys[self.keys.len() - 1].pos {
+            return self.keys[self.keys.len() - 1].color;
+        }
+        
+        for i in 0..self.keys.len() - 1 {
+            let k1 = &self.keys[i];
+            let k2 = &self.keys[i+1];
+            if t >= k1.pos && t <= k2.pos {
+                let range = k2.pos - k1.pos;
+                if range.abs() < 0.0001 {
+                    return k1.color;
+                }
+                let w = (t - k1.pos) / range;
+                return [
+                    k1.color[0] * (1.0 - w) + k2.color[0] * w,
+                    k1.color[1] * (1.0 - w) + k2.color[1] * w,
+                    k1.color[2] * (1.0 - w) + k2.color[2] * w,
+                ];
+            }
+        }
+        self.keys[0].color
+    }
+    
+    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());
+        if let Some(pos) = prev_selected_id {
+            if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - pos).abs() < 0.0001) {
+                self.selected_key_idx = Some(new_idx);
+            }
+        }
+    }
+}
+
+impl Element for Ramp {
+    crate::impl_widget_base!(Ramp);
+    
+    fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> { self.parent }
+    fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, _ctx: &mut UiContext) {
+        self.parent = parent;
+    }
+    
+    fn children(&self, _ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
+        let self_ptr = self as *const Self as *mut Self;
+        unsafe {
+            vec![
+                &mut (*self_ptr).r_slider as *mut Slider as *mut (dyn Element + 'static),
+                &mut (*self_ptr).g_slider as *mut Slider as *mut (dyn Element + 'static),
+                &mut (*self_ptr).b_slider as *mut Slider as *mut (dyn Element + 'static),
+                &mut (*self_ptr).del_button as *mut Button as *mut (dyn Element + 'static),
+            ]
+        }
+    }
+    
+    fn color(&self) -> [f32; 4] {
+        colors::ramp_background_color()
+    }
+    
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.base.x = x;
+        self.base.y = y;
+        self.base.w = w;
+        self.base.h = h;
+        
+        let self_ptr = self.as_ptr_mut();
+        let mut dummy = crate::context::UiContext::new();
+        self.r_slider.set_parent(Some(self_ptr), &mut dummy);
+        self.g_slider.set_parent(Some(self_ptr), &mut dummy);
+        self.b_slider.set_parent(Some(self_ptr), &mut dummy);
+        self.del_button.set_parent(Some(self_ptr), &mut dummy);
+        
+        let th = crate::layout::ramp_height();
+        let sy = y + th + 45.0;
+        let slider_w = w - 100.0;
+        
+        if self.selected_key_idx.is_some() {
+            self.r_slider.set_rect(x + 10.0, sy, slider_w, 20.0);
+            self.g_slider.set_rect(x + 10.0, sy + 25.0, slider_w, 20.0);
+            self.b_slider.set_rect(x + 10.0, sy + 50.0, slider_w, 20.0);
+            self.del_button.set_rect(x + w - 80.0, sy + 20.0, 70.0, 28.0);
+        } else {
+            self.r_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+            self.g_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+            self.b_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+            self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+        }
+    }
+    
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let mut quads = Vec::new();
+        let th = crate::layout::ramp_height();
+        let track_x = self.base.x + 10.0;
+        let track_w = self.base.w - 20.0;
+        
+        // Draw track border
+        let border_color = colors::ramp_border_color();
+        quads.push((track_x - 1.0, self.base.y - 1.0, track_w + 2.0, th + 2.0, border_color));
+        
+        // Draw interpolated track slices (e.g. 100 slices)
+        let slices = 100;
+        let slice_w = track_w / slices as f32;
+        for i in 0..slices {
+            let t1 = i as f32 / slices as f32;
+            let t2 = (i + 1) as f32 / slices as f32;
+            let center_t = (t1 + t2) / 2.0;
+            let col = self.get_interpolated_color(center_t);
+            let sx = track_x + t1 * track_w;
+            quads.push((sx, self.base.y, slice_w, th, [col[0], col[1], col[2], 1.0]));
+        }
+        
+        // Return child quads if selected
+        if self.selected_key_idx.is_some() {
+            let ctx_dummy = crate::context::UiContext::new();
+            quads.extend(self.r_slider.all_quads(&ctx_dummy));
+            quads.extend(self.g_slider.all_quads(&ctx_dummy));
+            quads.extend(self.b_slider.all_quads(&ctx_dummy));
+            quads.extend(self.del_button.all_quads(&ctx_dummy));
+        }
+        
+        quads
+    }
+    
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
+        let mut circles = Vec::new();
+        let th = crate::layout::ramp_height();
+        let track_x = self.base.x + 10.0;
+        let track_w = self.base.w - 20.0;
+        let py = self.base.y + th + 15.0;
+        
+        for (idx, key) in self.keys.iter().enumerate() {
+            let cx = track_x + key.pos * track_w;
+            
+            // Draw peg outline / shadow
+            circles.push((cx, py, 7.0, [0.0, 0.0, 0.0, 0.8]));
+            
+            // Draw peg color preview
+            circles.push((cx, py, 6.0, [key.color[0], key.color[1], key.color[2], 1.0]));
+            
+            // Highlight selected peg
+            if Some(idx) == self.selected_key_idx {
+                circles.push((cx, py, 8.0, [0.49, 1.0, 1.0, 0.5]));
+            }
+        }
+        
+        circles
+    }
+    
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py_event: f32, ctx: &mut UiContext) -> bool {
+        if button != MouseButton::Left { return false; }
+        
+        let th = crate::layout::ramp_height();
+        let track_x = self.base.x + 10.0;
+        let track_w = self.base.w - 20.0;
+        let py_peg = self.base.y + th + 15.0;
+        
+        if state == ElementState::Pressed {
+            // 1. Check peg click selection/dragging
+            for (idx, key) in self.keys.iter().enumerate() {
+                let cx = track_x + key.pos * track_w;
+                let dx = px - cx;
+                let dy = py_event - py_peg;
+                if (dx*dx + dy*dy) <= 64.0 { // hit circle radius 8.0
+                    self.selected_key_idx = Some(idx);
+                    self.is_dragging_key = true;
+                    
+                    // Update slider values to match key color
+                    self.r_slider.set_value(key.color[0]);
+                    self.g_slider.set_value(key.color[1]);
+                    self.b_slider.set_value(key.color[2]);
+                    
+                    self.set_rect(self.base.x, self.base.y, self.base.w, self.base.h);
+                    return true;
+                }
+            }
+            
+            // 2. Check track click to add key
+            if px >= track_x && px <= track_x + track_w && py_event >= self.base.y && py_event <= self.base.y + th {
+                let t = (px - track_x) / track_w;
+                let col = self.get_interpolated_color(t);
+                let new_key = RampKey { pos: t, color: col };
+                self.keys.push(new_key);
+                self.sort_keys();
+                
+                // Select newly added key
+                if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - t).abs() < 0.0001) {
+                    self.selected_key_idx = Some(new_idx);
+                    self.r_slider.set_value(col[0]);
+                    self.g_slider.set_value(col[1]);
+                    self.b_slider.set_value(col[2]);
+                }
+                
+                self.set_rect(self.base.x, self.base.y, self.base.w, self.base.h);
+                return true;
+            }
+            
+            // 3. Delegate to slider / button click
+            if self.selected_key_idx.is_some() {
+                if self.r_slider.mouse_input(button, state, px, py_event, ctx) { return true; }
+                if self.g_slider.mouse_input(button, state, px, py_event, ctx) { return true; }
+                if self.b_slider.mouse_input(button, state, px, py_event, ctx) { return true; }
+                if self.del_button.mouse_input(button, state, px, py_event, ctx) {
+                    // Check if clicked
+                    if self.del_button.take_click() {
+                        if let Some(idx) = self.selected_key_idx {
+                            if self.keys.len() > 2 {
+                                self.keys.remove(idx);
+                                self.selected_key_idx = None;
+                                self.set_rect(self.base.x, self.base.y, self.base.w, self.base.h);
+                            }
+                        }
+                    }
+                    return true;
+                }
+            }
+        } else {
+            // Mouse released
+            self.is_dragging_key = false;
+            
+            if self.selected_key_idx.is_some() {
+                self.r_slider.mouse_input(button, state, px, py_event, ctx);
+                self.g_slider.mouse_input(button, state, px, py_event, ctx);
+                self.b_slider.mouse_input(button, state, px, py_event, ctx);
+                if self.del_button.mouse_input(button, state, px, py_event, ctx) {
+                    if self.del_button.take_click() {
+                        if let Some(idx) = self.selected_key_idx {
+                            if self.keys.len() > 2 {
+                                self.keys.remove(idx);
+                                self.selected_key_idx = None;
+                                self.set_rect(self.base.x, self.base.y, self.base.w, self.base.h);
+                            }
+                        }
+                    }
+                }
+                return true;
+            }
+        }
+        
+        false
+    }
+    
+    fn cursor_moved(&mut self, px: f32, py_event: f32, ctx: &mut UiContext) -> bool {
+        let mut changed = false;
+        
+        let _th = crate::layout::ramp_height();
+        let track_x = self.base.x + 10.0;
+        let track_w = self.base.w - 20.0;
+        
+        // 1. Update key dragging
+        if self.is_dragging_key {
+            if let Some(idx) = self.selected_key_idx {
+                let t = ((px - track_x) / track_w).clamp(0.0, 1.0);
+                self.keys[idx].pos = t;
+                self.sort_keys();
+                changed = true;
+            }
+        }
+        
+        // 2. Delegate to sliders / buttons
+        if self.selected_key_idx.is_some() {
+            if self.r_slider.cursor_moved(px, py_event, ctx) {
+                if let Some(idx) = self.selected_key_idx {
+                    self.keys[idx].color[0] = self.r_slider.value();
+                    changed = true;
+                }
+            }
+            if self.g_slider.cursor_moved(px, py_event, ctx) {
+                if let Some(idx) = self.selected_key_idx {
+                    self.keys[idx].color[1] = self.g_slider.value();
+                    changed = true;
+                }
+            }
+            if self.b_slider.cursor_moved(px, py_event, ctx) {
+                if let Some(idx) = self.selected_key_idx {
+                    self.keys[idx].color[2] = self.b_slider.value();
+                    changed = true;
+                }
+            }
+            if self.del_button.cursor_moved(px, py_event, ctx) {
+                changed = true;
+            }
+        }
+        
+        changed
+    }
+    
+    fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+        let mut labels = Vec::new();
+        if self.selected_key_idx.is_some() {
+            labels.extend(self.r_slider.text_labels_with_font_and_bounds(ctx));
+            labels.extend(self.g_slider.text_labels_with_font_and_bounds(ctx));
+            labels.extend(self.b_slider.text_labels_with_font_and_bounds(ctx));
+            labels.extend(self.del_button.text_labels_with_font_and_bounds(ctx));
+        }
+        labels
+    }
+    
+    fn get_text_items(&self) -> Vec<(&glyphon::Buffer, f32, f32, glyphon::Color)> {
+        let mut items = Vec::new();
+        if self.selected_key_idx.is_some() {
+            items.extend(self.r_slider.get_text_items());
+            items.extend(self.g_slider.get_text_items());
+            items.extend(self.b_slider.get_text_items());
+            items.extend(self.del_button.get_text_items());
+        }
+        items
+    }
+}
+
+impl Drop for Ramp {
+    fn drop(&mut self) {
+        clear_widget_references(self);
+    }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index d055da2..afe0207 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -679,7 +679,7 @@ pub use self::input::{
     Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
     ColorSelector, Finger, Trackpad, Canvas, get_font_db, ActiveThumb, FontSelector,
     ButtonStrip, MultiControl, InstancedControl, InstancedWidget, MultiControlRow,
-    KeybindsControl, KeybindRow, KeybindRecorder
+    KeybindsControl, KeybindRow, KeybindRecorder, Ramp, RampKey
 };
 pub use self::container::{
     Container, ContainerLayout, OverlayLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,