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

commitb6071861d613f3854aa567fa1cc1dfc68eda35f2
parentb4604593c4
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 21:59
fix(system): unclipped buttons and text on the System page

- button_need asks the Button for its intrinsic size (the label shaped in
  the button font) instead of measure_text_width, whose inked extent runs
  ~20% short under Berkeley Mono. The action row was handed less than its
  labels and cut "Hibernate" and "Power Off" off.
- The four power actions fall back to two rows (safe pair over the
  destructive pair) when they do not fit one row, rather than being
  scaled below their labels.
- The buttons use the toolkit's button_height() instead of a hand-set
  32px, and the stacks leave no trailing gap under their last row.
- System, CPU, GPU and the pending System Files lines wrap to the well
  instead of being clipped by it.
- The hostname comes from /proc/sys/kernel/hostname. The `hostname`
  binary is not installed, so the System well read "—  Linux …".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/pages/system_info.rs | 174 +++++++++++++++++++++++++++++++++++------------
 1 file changed, 130 insertions(+), 44 deletions(-)

diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index a0e798f..f75b79c 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -261,14 +261,79 @@ where
     });
 }
 
-/// Width a label needs on a button plate: the measured text plus the plate's
-/// own 8px inset each side, and a little air. Feeds `add_row_for`, so a row of
+/// Width a label needs on a button plate. Feeds `add_row_for`, so a row of
 /// buttons is divided by what is written on them rather than into equal
 /// slices — "Reboot" and "Hibernate" are not the same size and a row that
 /// pretends otherwise clips one and pads the other.
+///
+/// Asks the Button itself (`intrinsic_size`: its label shaped in the button
+/// font, plus its 8px inset each side) rather than measuring here. This used
+/// `measure_text_width` in the control-label font, an inked extent that runs
+/// ~20% short of the shaped run under Berkeley Mono, so the row handed every
+/// button less than its label and "Hibernate" / "Power Off" were cut off.
 fn button_need(label: &str) -> f32 {
-    let (family, size) = cce_ui::layout::control_label_font_parsed();
-    cce_ui::widget::display::measure_text_width(label, &family, size) + 2.0 * cce_ui::layout::CONTROL_TEXT_INSET + 8.0
+    Button::new(0.0, 0.0, 0.0, 0.0)
+        .with_label(label)
+        .intrinsic_size()
+        .map_or(0.0, |s| s.width)
+}
+
+/// `text` broken into lines no wider than `width` at `size`, measured as the
+/// renderer shapes it (the default UI face, as `PageContent::text` draws).
+/// Breaks after a space or before a `/`, so a path splits on its separators;
+/// a run with neither is cut wherever it overflows. A section's text is
+/// clipped to its content box, so anything that does not fit has to wrap or
+/// it is simply lost — a GPU name, the CPU model, a pending install path.
+fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
+    let offsets = cce_ui::geometry_font_system()
+        .lock()
+        .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
+        .unwrap_or_default();
+    if offsets.last().map_or(true, |&(_, total)| total <= width) {
+        return vec![text.to_string()];
+    }
+    let mut lines = Vec::new();
+    let (mut start, mut start_x) = (0usize, 0.0f32);
+    // Where the next line would begin if this one broke at the last chance.
+    let mut chance: Option<(usize, f32)> = None;
+    for pair in offsets.windows(2) {
+        let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
+        if next_x - start_x > width && b > start {
+            let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
+            lines.push(text[start..cut].trim_end().to_string());
+            (start, start_x, chance) = (cut, cut_x, None);
+        }
+        match &text[b..next_b] {
+            " " => chance = Some((next_b, next_x)),
+            "/" if b > start => chance = Some((b, x)),
+            _ => {}
+        }
+    }
+    lines.push(text[start..].trim_end().to_string());
+    lines.retain(|l| !l.is_empty());
+    lines
+}
+
+/// [`SectionContext::text`](cce_ui::layout::SectionContext::text), wrapped
+/// to the content box instead of clipped by it. Continuation lines follow at
+/// the line pitch `text` itself advances by, without the row gap, so one
+/// wrapped item still reads as one item.
+fn wrapped_text(sec: &mut cce_ui::layout::SectionContext<'_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
+    use cce_ui::layout::RenderTarget;
+    let x = sec.content_left();
+    let right = x + sec.content_width();
+    let mut y = sec.content_y;
+    if y > sec.content_start_y {
+        y += sec.row_gap;
+    }
+    for line in wrap_to_width(text, sec.content_width(), size) {
+        sec.pc.text_with_bounds(&line, x, y, size, color, Some([x, y - size, right, y + 2.0 * size]));
+        y += size + 4.0;
+    }
+    sec.content_y = y;
+    for h in &mut sec.grid.col_heights {
+        *h = y;
+    }
 }
 
 fn spawn_systemctl(action: &str) {
@@ -379,9 +444,12 @@ pub struct SystemInfo {
 }
 
 pub async fn fetch_system_state() -> SystemInfo {
-    let hostname = tokio::process::Command::new("hostname")
-        .output().await.ok()
-        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().split('.').next().map(|s| s.to_string()))
+    // The kernel's own copy, not the `hostname` binary: inetutils is not
+    // part of a base Arch install, and without it the System well opened on
+    // a bare "—  Linux …" with nothing in front of the dash.
+    let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname")
+        .ok()
+        .and_then(|h| h.trim().split('.').next().map(|s| s.to_string()))
         .unwrap_or_default();
 
     let kernel = tokio::process::Command::new("uname")
@@ -503,42 +571,46 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
         if !state.loaded {
             sec.text("Loading system information...", 12.0, 0.0, 14.0, TEXT_FG);
         } else {
-            sec.text(&format!("{}  —  Linux {}", state.hostname, state.kernel), 12.0, 0.0, 14.0, TEXT_FG);
-            sec.text(&format!("Uptime: {}", state.uptime), 12.0, 0.0, 12.0, TEXT_DIM);
+            wrapped_text(sec, &format!("{}  —  Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
+            wrapped_text(sec, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
         }
     });
 
     // ── 2. System Actions Section ──
     builder.add_section(&mut final_pc, "System Actions", sec_focused.get(1).copied().unwrap_or(false), |sec| {
-        let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-        let act_btn_h = 32.0;
+        let gap = cce_ui::layout::plate_gap();
+        let mut stack = sec.vstack(gap);
+        // The toolkit's button height, like every other cce-ui button — this
+        // row was a hand-set 32px, a size no other control in the DE wears.
+        let btn_h = cce_ui::layout::button_height();
 
         const ACTIONS: [&str; 4] = ["Suspend", "Hibernate", "Reboot", "Power Off"];
         let needs: Vec<f32> = ACTIONS.iter().map(|l| button_need(l)).collect();
-        stack.add_row_for(&needs, cce_ui::layout::plate_gap(), act_btn_h, |ctx, i, x, w| {
-            match i {
-                0 => {
-                    ctx.button("Suspend", x, ctx.ay(), w, act_btn_h,
-                        SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Suspend));
-                }
-                1 => {
-                    ctx.button("Hibernate", x, ctx.ay(), w, act_btn_h,
-                        SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Hibernate));
-                }
-                2 => {
-                    ctx.button("Reboot", x, ctx.ay(), w, act_btn_h,
-                        DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Reboot));
-                }
-                3 => {
-                    ctx.button("Power Off", x, ctx.ay(), w, act_btn_h,
-                        DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::PowerOff));
-                }
-                _ => {}
-            }
-        });
+        // All four on one row when their labels fit; otherwise the safe pair
+        // over the destructive pair, rather than squeezing every plate below
+        // its label (`row_layout_for` scales an overfull row down, which is
+        // what clipped the labels).
+        let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32
+            <= stack.context.content_width();
+        let rows: &[std::ops::Range<usize>] = if one_row { &[0..4] } else { &[0..2, 2..4] };
+        for r in rows {
+            let first = r.start;
+            stack.add_row_for(&needs[r.clone()], gap, btn_h, |ctx, i, x, w| {
+                let (label, bg, msg) = match first + i {
+                    0 => ("Suspend", SAFE_BG, SystemMessage::Suspend),
+                    1 => ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
+                    2 => ("Reboot", DANGER_BG, SystemMessage::Reboot),
+                    _ => ("Power Off", DANGER_BG, SystemMessage::PowerOff),
+                };
+                ctx.button(label, x, ctx.ay(), w, btn_h, bg, BTN_HOVER, WHITE, AppAction::SystemInfo(msg));
+            });
+        }
 
-        stack.add_row(1, 0.0, act_btn_h, |ctx, _, x, w| {
-            ctx.button("Force Shutdown", x, ctx.ay(), w, act_btn_h,
+        // No trailing gap after the last row: the well's own floor inset is
+        // the space below it, as it is beside and above.
+        stack.spacing = 0.0;
+        stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
+            ctx.button("Force Shutdown", x, ctx.ay(), w, btn_h,
                 DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::ForceShutdown));
         });
     });
@@ -550,10 +622,10 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
         } else {
             // Same strings the old Label widgets carried, stacked vertically (the
             // grid-column widget placement overlapped them at narrow widths).
-            sec.text(&format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, 0.0, 12.0, TEXT_FG);
-            sec.text(&format!("Usage  {:.0}%", state.cpu_usage), 12.0, 0.0, 12.0, TEXT_FG);
+            wrapped_text(sec, &format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
+            wrapped_text(sec, &format!("Usage  {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
             let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
-            sec.text(&cpu_temp_text, 12.0, 0.0, 12.0, TEXT_FG);
+            wrapped_text(sec, &cpu_temp_text, 12.0, TEXT_FG);
         }
     });
 
@@ -563,7 +635,7 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
             sec_gpu.text("Loading GPU models...", 12.0, 0.0, 12.0, TEXT_FG);
         } else {
             for gpu_text in state.gpu_strings.iter() {
-                sec_gpu.text(gpu_text, 12.0, 0.0, 12.0, TEXT_FG);
+                wrapped_text(sec_gpu, gpu_text, 12.0, TEXT_FG);
             }
         }
     });
@@ -602,11 +674,20 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
 
         // Name the files. "2 files differ" is not enough to authorize a root
         // install on — which one is the greeter matters.
+        // A path pair is wider than the well, and the well clips: wrapped,
+        // continuation lines indented past the first, so each file still
+        // reads as one entry.
+        let wrap_w = stack.context.content_width();
         for line in sf.pending.iter().take(8) {
-            let line = line.clone();
-            stack.add_row(1, 0.0, 14.0, move |c, _, x, _| {
+            let lines = wrap_to_width(line, wrap_w - 8.0, 11.0);
+            let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
+            let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - 20.0, 11.0)).collect();
+            stack.add_row(1, 0.0, 14.0 * (1 + rest.len()) as f32, move |c, _, x, _| {
                 let y = c.ay();
-                c.pc.text(&line, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
+                c.pc.text(&first, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
+                for (k, l) in rest.iter().enumerate() {
+                    c.pc.text(l, x + 20.0, y + 3.0 + 14.0 * (k + 1) as f32, 11.0, TEXT_DIM);
+                }
             });
         }
 
@@ -615,13 +696,18 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                 Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
                 Err(e) => (format!("Failed: {}", e), DANGER_BG),
             };
-            stack.add_row(1, 0.0, 16.0, move |c, _, x, _| {
+            let lines = wrap_to_width(&msg, stack.context.content_width(), 11.0);
+            stack.add_row(1, 0.0, 16.0 + 14.0 * (lines.len().max(1) - 1) as f32, move |c, _, x, _| {
                 let y = c.ay();
-                c.pc.text(&msg, x, y + 4.0, 11.0, col);
+                for (k, l) in lines.iter().enumerate() {
+                    c.pc.text(l, x, y + 4.0 + 14.0 * k as f32, 11.0, col);
+                }
             });
         }
 
-        let btn_h = 32.0;
+        let btn_h = cce_ui::layout::button_height();
+        // The buttons close the well: no trailing gap below them.
+        stack.spacing = 0.0;
         let has_work = sf.scanned && !sf.pending.is_empty();
         let busy = sf.busy;
         let needs = [button_need("Check"), button_need("Install (root)")];