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