system settings
git clone https://git.lucas.co/cce-system-interface.git
services: a failed unit says so on its description line
Restores the one fact the status dot carried that nothing else did. A failed
unit offers the same play button a cleanly stopped one does, so since the dot
went there has been no sign on the row that it died.
`description_line` puts the marker at the FRONT and composes it before the
caller truncates — the ordering is the whole guarantee, since a long
description would otherwise be what pushes the marker off the end. The test
pins exactly that: a 500-char description truncated to 20 still reads
"failed" first.
It also stops truncating by byte slice. `&desc[..n]` panicked whenever the cut
landed inside a multi-byte character, and unit descriptions are free text;
`truncate_tail` is char-boundary safe and already carried the "..." tail.
Verified live in a shadow session against stub units covering each case: a
failed unit reads "failed — Fake unit that died on start" and still offers
play, an active one is unchanged, and a description of 200 multi-byte
characters — a guaranteed mid-character cut for the old code — truncates
cleanly instead of taking the app down.
src/pages/services.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 46 insertions(+), 7 deletions(-)
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 9bf5098..8e32c4d 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -188,12 +188,20 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
// column actually has (the item insets its labels by 8px).
let text_max_w = item_w - 16.0;
let max_chars = ((text_max_w / 6.0) as usize).max(10);
- let desc = if service.description.is_empty() { "No description" } else { &service.description };
- let desc_truncated = if desc.len() > max_chars {
- format!("{}...", &desc[..max_chars.saturating_sub(3)])
- } else {
- desc.to_string()
- };
+ // `failed` was the status dot's third state and has nowhere
+ // else to show: a failed unit offers the same play button a
+ // cleanly stopped one does, so without this the row gives no
+ // sign it died. It leads the line, and it goes in BEFORE the
+ // truncation so a long description can't be what pushes the
+ // marker off the end.
+ let failed = service.active_state == "failed" || service.sub_state == "failed";
+ // Char-boundary safe: the byte slice this replaced would
+ // panic whenever the cut landed inside a multi-byte
+ // character, and unit descriptions are free text.
+ let desc_truncated = cce_ui::widget::display::truncate_tail(
+ &description_line(failed, &service.description),
+ max_chars,
+ );
// Render InteractiveListItem background and text labels
// Rows dispatch as extra roots (the dissolved list is no parent).
@@ -294,6 +302,22 @@ const STARTING: (&str, &str) = ("activating", "starting");
const STOPPING: (&str, &str) = ("deactivating", "stopping");
const RESTARTING: (&str, &str) = ("activating", "restarting");
+/// The row's second line: the unit description, led by `failed` when it is.
+///
+/// `failed` was the status dot's third state and has nowhere else to show — a
+/// failed unit offers the same play button a cleanly stopped one does, so
+/// without this the row gives no sign it died. The marker LEADS, and is
+/// composed before the caller truncates, so a long description can never be
+/// what pushes it off the end.
+pub fn description_line(failed: bool, description: &str) -> String {
+ match (failed, description.is_empty()) {
+ (true, true) => "failed".to_string(),
+ (true, false) => format!("failed \u{2014} {description}"),
+ (false, true) => "No description".to_string(),
+ (false, false) => description.to_string(),
+ }
+}
+
/// Whether the row's single transport button offers Stop (`true`) or Start
/// (`false`) — which is also which icon it wears.
///
@@ -505,7 +529,7 @@ impl crate::pages::AppPage for ServicesState {
#[cfg(test)]
mod tests {
- use super::{transport_running, RESTARTING, STARTING, STOPPING};
+ use super::{description_line, transport_running, RESTARTING, STARTING, STOPPING};
#[test]
fn settled_states_pick_the_opposite_action() {
@@ -529,4 +553,19 @@ mod tests {
// must not flicker to Start while it cycles.
assert!(transport_running(RESTARTING.0, RESTARTING.1), "Restart must keep showing Stop");
}
+
+ #[test]
+ fn the_failed_marker_leads_and_survives_truncation() {
+ assert_eq!(description_line(false, "Bluetooth service"), "Bluetooth service");
+ assert_eq!(description_line(false, ""), "No description");
+ assert_eq!(description_line(true, ""), "failed");
+ assert!(description_line(true, "Bluetooth service").starts_with("failed"));
+
+ // The row truncates whatever this returns, so the marker is only
+ // guaranteed visible while it stays at the FRONT.
+ let long = description_line(true, &"x".repeat(500));
+ let shown = cce_ui::widget::display::truncate_tail(&long, 20);
+ assert!(shown.starts_with("failed"), "got {shown:?}");
+ assert!(shown.chars().count() <= 20);
+ }
}