system settings
git clone https://git.lucas.co/cce-system-interface.git
services: one transport button that shows the action it will take
Start and Stop were never both live — exactly one of them did anything on any
given row, and the other sat dimmed taking up space. They collapse into a
single button wearing the icon of the action available: play on a stopped
unit, stop on a running one. Restart is unchanged beside it, and the width
the second button gave up goes to the service description.
The flip is immediate rather than a refresh later, which is what makes it read
as a toggle: `update` already marked the unit `activating`/`deactivating` the
moment the button was pressed, and `transport_running` counts a transitional
state as the state it is heading FOR, so the icon changes under the pointer.
Those two halves are now bound together — `update` writes through the named
STARTING/STOPPING/RESTARTING pairs and the tests feed those same pairs back
through `transport_running`, so a change to one that breaks the other fails.
Restart is in there too: it keeps the unit running throughout, so the button
must not flicker to play while it cycles.
`transport_running` is deliberately not the `is_active` the status dot reads.
The dot reports what is confirmed true, the button reports what it will do —
during activation they legitimately disagree.
Verified live in a shadow session against a STUB `systemctl` on PATH, which
lists two fake user units and swallows every action: clicking play on the idle
one flipped the button to red stop in the same frame, and the stub logged
`--user start fake-idle.service`, so the dispatch and the flip are both real.
The stub is the point — the shadow shares the real XDG_RUNTIME_DIR and the
real polkit session, so clicking a genuine row would have started a service on
the live machine or popped an auth prompt on the user's screen.
src/pages/services.rs | 133 ++++++++++++++++++++++++++++++++++----------------
1 file changed, 92 insertions(+), 41 deletions(-)
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 787d84b..b9254be 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -175,14 +175,13 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
let restart_x = right_edge - r_btn_w;
- let stop_x = restart_x - btn_gap - btn_w;
- let start_x = stop_x - btn_gap - btn_w;
+ let toggle_x = restart_x - btn_gap - btn_w;
let btn_y = draw_y + (item_h - 22.0) / 2.0;
let btn_h = 22.0;
// Service Description (Truncate dynamically based on remaining space before Start button)
- let text_max_w = (start_x - 8.0) - (list_box_x + 32.0);
+ let text_max_w = (toggle_x - 8.0) - (list_box_x + 32.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 {
@@ -210,14 +209,22 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
let active_txt = [0.90, 0.90, 0.95, 1.0];
- let disabled_txt = [0.40, 0.40, 0.45, 1.0];
-
- // 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;
+
+ // ONE transport button, showing the action it will take:
+ // play on a stopped service, stop on a running one. The two
+ // dimmed half-buttons this replaced were never both live —
+ // exactly one of them did anything on any given row.
+ //
+ // `running` is NOT the `is_active` the status dot reads.
+ // `update` sets a transitional state the instant the button
+ // is clicked (activating/starting, deactivating/stopping),
+ // and counting those as the state they are heading for is
+ // what flips this icon under the pointer instead of leaving
+ // it stale until the next refresh lands. The dot keeps
+ // reporting the confirmed state: the button says what it
+ // will do, the dot says what is true.
+ let running = transport_running(&service.active_state, &service.sub_state);
+
// 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.
@@ -227,34 +234,23 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
("\u{25b6}", "\u{25a0}", "\u{27f3}")
};
- // Start button
- sec.pc.button_icon(
- "play",
- start_lbl,
- start_x,
- btn_y,
- btn_w,
- btn_h,
- 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
+ // Start/Stop, collapsed
sec.pc.button_icon(
- "stop",
- stop_lbl,
- stop_x,
+ if running { "stop" } else { "play" },
+ if running { stop_lbl } else { start_lbl },
+ toggle_x,
btn_y,
btn_w,
btn_h,
- 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)),
+ if running { [0.55, 0.16, 0.16, 0.3] } else { [0.16, 0.35, 0.18, 0.4] },
+ if running { [0.70, 0.22, 0.22, 0.5] } else { [0.22, 0.45, 0.25, 0.6] },
+ active_txt,
+ 1.0,
+ crate::app::AppAction::Services(if running {
+ ServicesMessage::Stop(service.name.clone(), service.is_system)
+ } else {
+ ServicesMessage::Start(service.name.clone(), service.is_system)
+ }),
);
// Restart button
@@ -288,6 +284,33 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
final_pc
}
+/// The optimistic states [`update`] writes the instant a transport button is
+/// clicked, before systemd has said anything back. Named because
+/// [`transport_running`] has to agree with them: the whole point of writing
+/// them is that the button's icon flips under the pointer instead of staying
+/// stale until the next refresh lands.
+const STARTING: (&str, &str) = ("activating", "starting");
+const STOPPING: (&str, &str) = ("deactivating", "stopping");
+const RESTARTING: (&str, &str) = ("activating", "restarting");
+
+/// Whether the row's single transport button offers Stop (`true`) or Start
+/// (`false`) — which is also which icon it wears.
+///
+/// A transitional state counts as the state it is heading FOR, not the one it
+/// is leaving. That is what makes the click feel like a toggle: `update` marks
+/// the unit `activating` the moment Start is pressed, and this reads that as
+/// running, so the icon becomes Stop immediately.
+///
+/// This is deliberately NOT the `is_active` the status dot reads. The dot
+/// reports what is confirmed true; the button reports what it will do.
+pub fn transport_running(active_state: &str, sub_state: &str) -> bool {
+ match active_state {
+ "active" | "activating" | "reloading" => true,
+ "deactivating" => false,
+ _ => sub_state == "running",
+ }
+}
+
pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
match msg {
ServicesMessage::Refreshed(new_services) => {
@@ -302,22 +325,22 @@ pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
}
ServicesMessage::Start(name, is_system) => {
if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
- srv.active_state = "activating".to_string();
- srv.sub_state = "starting".to_string();
+ srv.active_state = STARTING.0.to_string();
+ srv.sub_state = STARTING.1.to_string();
}
service_action(&name, "start", is_system);
}
ServicesMessage::Stop(name, is_system) => {
if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
- srv.active_state = "deactivating".to_string();
- srv.sub_state = "stopping".to_string();
+ srv.active_state = STOPPING.0.to_string();
+ srv.sub_state = STOPPING.1.to_string();
}
service_action(&name, "stop", is_system);
}
ServicesMessage::Restart(name, is_system) => {
if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
- srv.active_state = "activating".to_string();
- srv.sub_state = "restarting".to_string();
+ srv.active_state = RESTARTING.0.to_string();
+ srv.sub_state = RESTARTING.1.to_string();
}
service_action(&name, "restart", is_system);
}
@@ -478,3 +501,31 @@ impl crate::pages::AppPage for ServicesState {
self.loaded && self.list.keyboard(event)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{transport_running, RESTARTING, STARTING, STOPPING};
+
+ #[test]
+ fn settled_states_pick_the_opposite_action() {
+ assert!(transport_running("active", "running"));
+ assert!(!transport_running("inactive", "dead"));
+ assert!(!transport_running("failed", "failed"));
+ // sub_state alone is enough — some units report it without
+ // active_state catching up.
+ assert!(transport_running("something-else", "running"));
+ }
+
+ #[test]
+ fn a_click_flips_the_icon_before_any_refresh() {
+ // The exact states `update` writes optimistically. If these two ever
+ // disagree, the button stops feeling like a toggle: the icon would sit
+ // on the old action until systemd's next list lands, and a second
+ // click would re-send the action already in flight.
+ assert!(transport_running(STARTING.0, STARTING.1), "Start must show Stop immediately");
+ assert!(!transport_running(STOPPING.0, STOPPING.1), "Stop must show Start immediately");
+ // Restart keeps the unit running throughout, so the transport button
+ // must not flicker to Start while it cycles.
+ assert!(transport_running(RESTARTING.0, RESTARTING.1), "Restart must keep showing Stop");
+ }
+}