git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit39b319bba5dfd1caa056b3b50d3335b2b408a8db
parent590822d7ae
authorLucas Galante <[email protected]>
date2026-08-25 10:09
services: transport icons instead of Start/Stop/Restart labels

The three per-row buttons now wear cce-icons glyphs (play/stop/refresh), so
all three are the same square and the room "Restart" needed goes back to the
service description.

This app drew no images at all before: it is a flat host, `all_quads` carries
quads and the text list carries labels, and an icon is neither. So the page
now collects each button's icon rect from `Button::icon_rect` — the same
source `Button::paint` reads, so the glyph sits where the paint path would
put it — and `display_list` draws them under the page-viewport clip. That
clip is load-bearing: an image has no geometry to trim, so the clip is the
only thing that cuts a half-scrolled row's icon at the list edge.

Two things kept honest:

- The label survives as `button_icon`'s FALLBACK, because `upload_icon`
  returns None when the icon set is missing and a control that loses its face
  has no affordance left. The button widths follow the same test: at 24px a
  fallback label doesn't truncate so much as invert — it centers, both ends
  cut, and "Restart" reads "sta". Verified against an empty CCE_ICONS_DIR:
  full words at their old widths.
- Disabled state dims the glyph (alpha 0.35) rather than graying it, since an
  image carries no color to gray.

The dispatch clone still runs for an icon button — only the label block is
skipped — re-checked live by driving the System/User Services tabs, which
register through the same path.

 src/app.rs            | 26 ++++++++++++++++++++++++++
 src/main.rs           | 24 ++++++++++++++++++++++++
 src/pages/services.rs | 48 +++++++++++++++++++++++++++++++++++++++---------
 src/renderer.rs       | 25 +++++++++++++++++++++++++
 4 files changed, 114 insertions(+), 9 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 5624342..2165dd8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -269,6 +269,32 @@ impl PageContent {
         self.buttons.push((btn, action, clip));
     }
 
+    /// A button whose face is a bundled cce-icons glyph instead of a label.
+    ///
+    /// `label` stays as the FALLBACK: `upload_icon` returns `None` when the
+    /// icon set is missing or unparsable, and a control that silently loses
+    /// its face has no affordance left at all — so the button degrades to the
+    /// word rather than to an empty box. `alpha` dims the glyph for a disabled
+    /// control, which is the only state lever an icon has (images carry no
+    /// color).
+    pub fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32,
+                       bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
+                       alpha: f32, action: AppAction) {
+        if self.measure_only { return; }
+        let mut btn = cce_ui::widget::Button::new(x, y, w, h)
+            .with_label(label)
+            .with_bg(bg)
+            .with_hover_bg(hover_bg)
+            .with_label_color(label_color);
+        // 32px on the longer side: the toolkit caches the upload per (name, px),
+        // so every row's Start button shares one texture.
+        if let Some((id, iw, ih)) = cce_ui::upload_icon(icon, 32) {
+            btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(alpha);
+        }
+        let clip = self.clip_stack.last().copied();
+        self.buttons.push((btn, action, clip));
+    }
+
     pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
                        bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
                        action: AppAction) {
diff --git a/src/main.rs b/src/main.rs
index af72d5a..cc975a4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -109,6 +109,11 @@ struct SystemInterface {
     /// Control troughs from `PageContent::control_reliefs` (page coords,
     /// pre-scroll) — each carved as a flush inset plate after the section wells.
     page_control_reliefs: Vec<ControlCarve>,
+    /// Icon faces for the page's buttons — `(image, x, y, w, h, alpha)`, page
+    /// coords already scroll-shifted by the renderer. A flat host draws no
+    /// images at all otherwise: `all_quads` carries quads and the text list
+    /// carries labels, and an icon is neither.
+    page_button_images: Vec<(u32, f32, f32, f32, f32, f32)>,
     // Root Backplate + StatusBar DISSOLVED (Phase 6s): the window plate and the status
     // bar are emitted as tuples in rebuild_layout.
     sans_serif_family: String,
@@ -202,6 +207,7 @@ impl cce_ui::engine::Application for SystemInterface {
             content_h: 0.0,
             page_reliefs: Vec::new(),
             page_control_reliefs: Vec::new(),
+            page_button_images: Vec::new(),
             sans_serif_family: sans_family,
             serif_family,
             monospace_family,
@@ -431,6 +437,24 @@ impl cce_ui::engine::Application for SystemInterface {
             });
         }
 
+        // Button icon faces, over the page's quads and its carves — clipped to
+        // the page viewport, which is what cuts a half-scrolled list row's icon
+        // at the list edge (an image has no geometry to trim, only a clip).
+        if !self.page_button_images.is_empty() {
+            let view = Rect {
+                x: self.sidebar_width,
+                y: self.header_height,
+                width: width - self.sidebar_width,
+                height: (height - self.header_height - self.status_height
+                    - if self.search_open { 42.0 } else { 0.0 }).max(0.0),
+            };
+            pc.clip(view, |pc| {
+                for &(image, x, y, w, h, alpha) in &self.page_button_images {
+                    pc.image(image, Rect { x, y, width: w, height: h }, alpha);
+                }
+            });
+        }
+
         cce_ui::widget::hover_animation::post_render_check();
         if let Some((qx, qy, qw, qh, qc)) = cce_ui::widget::hover_animation::get_quad() {
             pc.quad(Rect { x: qx, y: qy - self.scroll_y, width: qw, height: qh }, qc);
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 0891ec6..787d84b 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -154,10 +154,23 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                 if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
                     let is_active = service.active_state == "active" || service.sub_state == "running";
 
-                    // Control buttons: Start, Stop, Restart on the right
+                    // Control buttons: Start, Stop, Restart on the right. With
+                    // icon faces all three are the same square, and the extra
+                    // room "Restart" needed goes back to the description.
+                    //
+                    // Sized on whether the icon set is actually THERE:
+                    // `button_icon` falls back to the labels when it isn't, and
+                    // a 24px button doesn't clip a label so much as replace it
+                    // — the text centers, so both ends cut and "Restart" reads
+                    // "sta". `upload_icon` caches per (name, px), so asking
+                    // every row costs one hash lookup.
+                    let icons_ok = cce_ui::upload_icon("play", 32).is_some();
                     let is_small = sec_w < 350.0;
-                    let btn_w = if is_small { 24.0 } else { 46.0 };
-                    let r_btn_w = if is_small { 24.0 } else { 54.0 };
+                    let (btn_w, r_btn_w) = match (icons_ok, is_small) {
+                        (true, _) => (24.0, 24.0),
+                        (false, true) => (24.0, 24.0),
+                        (false, false) => (46.0, 54.0),
+                    };
                     let btn_gap = if is_small { 4.0 } else { 6.0 };
                     let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
 
@@ -199,12 +212,24 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                     let active_txt = [0.90, 0.90, 0.95, 1.0];
                     let disabled_txt = [0.40, 0.40, 0.45, 1.0];
 
-                    let start_lbl = if is_small { "▶" } else { "Start" };
-                    let stop_lbl = if is_small { "■" } else { "Stop" };
-                    let restart_lbl = if is_small { "⟳" } else { "Restart" };
+                    // Icon faces (cce-icons `play`/`stop`/`refresh`); the words
+                    // ride along as the fallback `button_icon` falls back to
+                    // when the icon set is missing. A control that can't act
+                    // dims its glyph rather than graying it — an image carries
+                    // no color to gray.
+                    let dim = 0.35;
+                    // Fallback labels only — an icon face never draws them.
+                    // Without the icons a narrow row is back to needing the
+                    // one-glyph words it used before.
+                    let (start_lbl, stop_lbl, restart_lbl) = if icons_ok || !is_small {
+                        ("Start", "Stop", "Restart")
+                    } else {
+                        ("\u{25b6}", "\u{25a0}", "\u{27f3}")
+                    };
 
                     // Start button
-                    sec.pc.button(
+                    sec.pc.button_icon(
+                        "play",
                         start_lbl,
                         start_x,
                         btn_y,
@@ -213,11 +238,13 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                         if !is_active { [0.16, 0.35, 0.18, 0.4] } else { [0.12, 0.12, 0.16, 0.1] },
                         [0.22, 0.45, 0.25, 0.6],
                         if !is_active { active_txt } else { disabled_txt },
+                        if !is_active { 1.0 } else { dim },
                         crate::app::AppAction::Services(ServicesMessage::Start(service.name.clone(), service.is_system)),
                     );
 
                     // Stop button
-                    sec.pc.button(
+                    sec.pc.button_icon(
+                        "stop",
                         stop_lbl,
                         stop_x,
                         btn_y,
@@ -226,11 +253,13 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                         if is_active { [0.55, 0.16, 0.16, 0.3] } else { [0.12, 0.12, 0.16, 0.1] },
                         [0.70, 0.22, 0.22, 0.5],
                         if is_active { active_txt } else { disabled_txt },
+                        if is_active { 1.0 } else { dim },
                         crate::app::AppAction::Services(ServicesMessage::Stop(service.name.clone(), service.is_system)),
                     );
 
                     // Restart button
-                    sec.pc.button(
+                    sec.pc.button_icon(
+                        "refresh",
                         restart_lbl,
                         restart_x,
                         btn_y,
@@ -239,6 +268,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                         [0.15, 0.28, 0.45, 0.3],
                         [0.20, 0.38, 0.58, 0.5],
                         active_txt,
+                        1.0,
                         crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
                     );
                 }
diff --git a/src/renderer.rs b/src/renderer.rs
index 6456898..c6231f4 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -105,6 +105,7 @@ impl SystemInterface {
         let mut widgets = Vec::new();
         let mut texts = Vec::new();
         let mut page_buttons = Vec::new();
+        let mut page_button_images: Vec<(u32, f32, f32, f32, f32, f32)> = Vec::new();
 
         cce_ui::widget::hover_animation::reset_frame_registration();
         self.ui_context.clear_popovers();
@@ -504,6 +505,28 @@ impl SystemInterface {
                 radius: cce_ui::layout::button_corner_radius() * s,
                 corners: (true, true, true, true),
             });
+            // An icon face replaces the label entirely (as it does in
+            // `Button::paint`). The rect comes from the button's own
+            // `icon_rect` so the glyph lands where the paint path would put
+            // it; display_list draws these under the page clip, which is what
+            // cuts a half-scrolled row's icon at the list edge.
+            let has_icon = if let Some((image, irect, alpha)) =
+                btn.icon_rect(cce_ui::scene::layout::Rect {
+                    x: base.x,
+                    y: base.y - scroll_offset_y,
+                    width: base.w,
+                    height: base.h,
+                })
+            {
+                page_button_images.push((image, irect.x, irect.y, irect.width, irect.height, alpha));
+                true
+            } else {
+                false
+            };
+
+            // The label is skipped for an icon face, but NOT the dispatch clone
+            // below it: an icon button still has to be clickable.
+            if !has_icon {
             let label = base.label.as_deref().unwrap_or("");
             let label_size = 12.0;
              let buf = make_text_buffer_with_font(
@@ -564,6 +587,7 @@ impl SystemInterface {
                 btn.widget_font(),
                 button_bounds,
             ));
+            }
             let mut btn_clone = btn.clone();
             {
                 // The dispatch clone hit-tests at the CLAMPED rect, so clicks in
@@ -675,6 +699,7 @@ impl SystemInterface {
         self.widgets = widgets;
         self.texts = texts;
         self.page_buttons = page_buttons;
+        self.page_button_images = page_button_images;
 
         // The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
         // through the registry, and `clear_hierarchy` above wiped it. The view pass