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

commit5819a5d6507665c60c2db0817a1c6c88f1d78f01
parent9009101b8a
authorLucas Galante <[email protected]>
date2026-07-23 14:42
feat: icon-face buttons + upload_icon; ramp delete becomes the x icon tile

cce_ui::upload_icon(name, px) rasterizes a bundled cce-icons SVG with resvg
and uploads it once as a renderer texture (cached per name+size, so widget
rebuilds reuse the upload; None when the icon set is absent). Button gains
with_icon: the texture draws centered in place of a label, native aspect
kept, one 4px margin per side.

The ramp's "Delete" button becomes a square x-icon tile sized to the strip
height (the label fallback keeps the wider column when the icon set is
missing on a machine).

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

 src/lib.rs                 | 49 ++++++++++++++++++++++++++++++++++++++++++++++
 src/widget/input/button.rs | 34 ++++++++++++++++++++++++++++++++
 src/widget/input/ramp.rs   | 14 ++++++++++---
 3 files changed, 94 insertions(+), 3 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 469a53c..531afb4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -42,6 +42,55 @@ pub fn icons_dir() -> String {
     })
 }
 
+/// Rasterize a bundled cce-icons SVG (`<name>.svg` under [`icons_dir`]) at
+/// `px` on its longer side and upload it as a renderer texture. Returns
+/// `(image id, pixel w, pixel h)` for `PaintCtx::image` / `ImageView`; cached
+/// per `(name, px)` so widget rebuilds reuse the one upload. `None` when the
+/// icon is missing or unparsable (callers keep a text fallback).
+pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
+    use std::collections::HashMap;
+    use std::sync::Mutex;
+    static CACHE: Mutex<Option<HashMap<(String, u32), Option<(u32, u32, u32)>>>> =
+        Mutex::new(None);
+    let key = (name.to_string(), px);
+    let mut guard = CACHE.lock().unwrap();
+    let cache = guard.get_or_insert_with(HashMap::new);
+    if let Some(hit) = cache.get(&key) {
+        return *hit;
+    }
+    let loaded = (|| {
+        let path = format!("{}/{name}.svg", icons_dir());
+        let data = std::fs::read(&path).ok()?;
+        let opt = resvg::usvg::Options::default();
+        let fontdb = crate::widget::get_font_db();
+        let tree = resvg::usvg::Tree::from_data(&data, &opt, fontdb).ok()?;
+        let size = tree.size();
+        let (sw, sh) = (size.width().max(1.0), size.height().max(1.0));
+        let scale = px as f32 / sw.max(sh);
+        let w = (sw * scale).round().max(1.0) as u32;
+        let h = (sh * scale).round().max(1.0) as u32;
+        let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?;
+        resvg::render(
+            &tree,
+            resvg::tiny_skia::Transform::from_scale(scale, scale),
+            &mut pixmap.as_mut(),
+        );
+        // tiny-skia pixels are premultiplied; the upload path takes straight RGBA.
+        let mut rgba = pixmap.take();
+        for p in rgba.chunks_exact_mut(4) {
+            let a = p[3] as f32 / 255.0;
+            if a > 0.0 {
+                p[0] = ((p[0] as f32 / a).min(255.0)) as u8;
+                p[1] = ((p[1] as f32 / a).min(255.0)) as u8;
+                p[2] = ((p[2] as f32 / a).min(255.0)) as u8;
+            }
+        }
+        Some((crate::vk::upload_rgba(rgba, w, h), w, h))
+    })();
+    cache.insert(key, loaded);
+    loaded
+}
+
 /// Build a glyphon `FontSystem` loaded with the bundled CCE fonts (house style).
 /// System fonts are loaded only if `$CCE_LOAD_SYSTEM_FONTS` is set. Configured
 /// custom fonts are validated with a warning if missing.
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 6327bd1..d1338c2 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -32,6 +32,9 @@ pub struct Button {
     pub label_color: Option<[f32; 4]>,
     pub justify: Justification,
     label: Option<String>,
+    /// Icon face: an uploaded texture `(image id, pixel w, pixel h)` drawn
+    /// centered in place of the label (see [`crate::upload_icon`]).
+    icon: Option<(u32, f32, f32)>,
     hovered: bool,
     /// Raised style: the background is an SDF-lit `Bevel` plate — fill plus a
     /// rolled, lit edge — instead of a flat fill + border stroke.
@@ -65,6 +68,7 @@ impl Button {
             label_color: None,
             justify: Justification::Center,
             label: None,
+            icon: None,
             hovered: false,
             raised: crate::layout::control_relief(),
         }
@@ -92,6 +96,11 @@ impl Button {
         Button::adapted(ButtonKind::CopyIcon, x, y, w, h)
     }
 
+    /// Whether an icon face is set (hosts size icon buttons square).
+    pub fn has_icon(&self) -> bool {
+        self.icon.is_some()
+    }
+
     /// Hover state, also settable by immediate-mode hosts that hit-test themselves.
     pub fn hovered(&self) -> bool {
         self.hovered
@@ -125,6 +134,14 @@ impl Button {
 /// `Adapted::with_label`, which syncs the model's copy via `Paint::sync_label`).
 impl Adapted<Button> {
 
+    /// Icon face: draw this uploaded texture centered in place of a label —
+    /// pass [`crate::upload_icon`]'s `(id, w, h)`. Pairs with a plain `new()`
+    /// (no `with_label`), so the legacy label views stay empty.
+    pub fn with_icon(mut self, image: u32, w: f32, h: f32) -> Self {
+        self.icon = Some((image, w, h));
+        self
+    }
+
     /// Raised style: see the `raised` field.
     pub fn with_raised(mut self, raised: bool) -> Self {
         self.raised = raised;
@@ -304,6 +321,23 @@ impl Paint for Button {
             }
         }
 
+        // Icon face: centered, inset one 4px margin per side from the shorter
+        // extent, native aspect kept. Replaces the label.
+        if let Some((image, iw, ih)) = self.icon {
+            let s = (w.min(h) - 8.0).max(4.0);
+            let (dw, dh) = if iw >= ih {
+                (s, s * ih / iw.max(1.0))
+            } else {
+                (s * iw / ih.max(1.0), s)
+            };
+            ctx.image(
+                image,
+                Rect { x: x + (w - dw) / 2.0, y: y + (h - dh) / 2.0, width: dw, height: dh },
+                1.0,
+            );
+            return;
+        }
+
         // Label, with per-kind justification/color (legacy `text_labels`).
         if let Some(ref label) = self.label {
             let (_, font_size) = self.font();
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 1e2dc7a..4bce152 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -134,7 +134,14 @@ impl Ramp {
         ];
         
         let val_slider = Slider::new();
-        let del_button = Button::new(0.0, 0.0, 64.0, 22.0).with_label("Delete");
+        // A square x-icon button (cce-icons); label fallback if the icon set
+        // is missing on this machine.
+        let del_button = match crate::upload_icon("x", 32) {
+            Some((id, w, h)) => {
+                Button::new(0.0, 0.0, 22.0, 22.0).with_icon(id, w as f32, h as f32)
+            }
+            None => Button::new(0.0, 0.0, 64.0, 22.0).with_label("Delete"),
+        };
         // Short names on purpose: the strip's columns are narrow, and these
         // render inside param rows too ("Bevel (Raised)" used to clip).
         let preset_dropdown = Dropdown::new(
@@ -802,8 +809,9 @@ impl Ramp {
         let gap = 10.0;
 
         if self.selected_key_idx.is_some() {
-            // Four columns: preset, line type, value, and the delete button.
-            let del_w: f32 = 64.0;
+            // Four columns: preset, line type, value, and the delete button —
+            // a square x-icon tile (label fallback runs wider).
+            let del_w: f32 = if self.del_button.inner().has_icon() { ctrl_h } else { 64.0 };
             let avail = (track_w - del_w - 3.0 * gap).max(120.0);
             let pre_w = (avail * 0.40).max(40.0);
             let line_w = (avail * 0.32).max(40.0);