system settings
git clone https://git.lucas.co/cce-system-interface.git
Update system configuration and interface modules
Cargo.lock | 1 +
Cargo.toml | 1 +
src/app.rs | 17 ++
src/main.rs | 165 +++++++++++++--
src/pages/input.rs | 53 ++++-
src/pages/mod.rs | 6 +-
src/pages/services.rs | 553 ++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 782 insertions(+), 14 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 812100a..ed8a1f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -317,6 +317,7 @@ dependencies = [
"pollster",
"raw-window-handle",
"serde",
+ "serde_json",
"smithay-client-toolkit",
"tokio",
"wayland-client",
diff --git a/Cargo.toml b/Cargo.toml
index deae035..4dbc9ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ tokio = { version = "1", features = ["full"] }
zbus = "5"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
+serde_json = "1"
[lib]
name = "clear_system_interface"
diff --git a/src/app.rs b/src/app.rs
index 5a52a7d..353ea6c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -13,6 +13,7 @@ use crate::pages::storage;
use crate::pages::system_info;
use crate::pages::backup;
use crate::pages::typeface;
+use crate::pages::services;
use crate::pages::Page;
pub struct AppState {
@@ -30,6 +31,7 @@ pub struct AppState {
pub notifications: notifications::NotificationsState,
pub backup: backup::BackupState,
pub typeface: typeface::TypefaceState,
+ pub services: services::ServicesState,
}
impl Default for AppState {
@@ -49,6 +51,7 @@ impl Default for AppState {
notifications: notifications::read_notifications_config(),
backup: backup::BackupState::default(),
typeface: typeface::TypefaceState::default(),
+ services: services::ServicesState::default(),
}
}
}
@@ -68,6 +71,7 @@ pub enum AppAction {
Notifications(notifications::NotificationsMessage),
Backup(backup::BackupMessage),
Typeface(typeface::TypefaceMessage),
+ Services(services::ServicesMessage),
}
pub struct PageContent {
@@ -85,6 +89,7 @@ pub struct ContentButton {
pub label_size: f32,
pub label_color: [f32; 4],
pub action: AppAction,
+ pub left_align: bool,
}
impl PageContent {
@@ -111,6 +116,18 @@ impl PageContent {
x, y, w, h, bg, hover_bg,
label: label.to_string(), label_size: 12.0, label_color,
action,
+ left_align: false,
+ });
+ }
+
+ 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) {
+ self.buttons.push(ContentButton {
+ x, y, w, h, bg, hover_bg,
+ label: label.to_string(), label_size: 12.0, label_color,
+ action,
+ left_align: true,
});
}
}
diff --git a/src/main.rs b/src/main.rs
index 29b3a10..6d71f67 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -157,6 +157,7 @@ struct SystemInterface {
rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
rx_layout: std::sync::mpsc::Receiver<pages::layout::LayoutState>,
rx_input: std::sync::mpsc::Receiver<pages::input::InputState>,
+ rx_fingers: std::sync::mpsc::Receiver<Vec<pages::input::Finger>>,
rx_processors: std::sync::mpsc::Receiver<pages::processors::ProcessorsState>,
rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemState>,
rx_status: std::sync::mpsc::Receiver<pages::status::StatusState>,
@@ -164,6 +165,7 @@ struct SystemInterface {
rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsState>,
rx_backup_state: std::sync::mpsc::Receiver<pages::backup::BackupState>,
rx_typeface: std::sync::mpsc::Receiver<pages::typeface::TypefaceState>,
+ rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
tx_backup: std::sync::mpsc::Sender<pages::backup::BackupMessage>,
rx_backup: std::sync::mpsc::Receiver<pages::backup::BackupMessage>,
tx_color_selector: std::sync::mpsc::Sender<ColorSelectorAction>,
@@ -185,8 +187,10 @@ impl SystemInterface {
xdg_shell_state: &XdgShell,
width: u32,
height: u32,
+ scale: f64,
) -> Self {
let surface = compositor_state.create_surface(qh);
+ surface.set_buffer_scale(scale as i32);
let window = xdg_shell_state.create_window(surface.clone(), WindowDecorations::None, qh);
window.set_title("Clear System Interface");
window.set_app_id("clear-system-interface");
@@ -325,6 +329,28 @@ impl SystemInterface {
});
rx
};
+ let rx_fingers = {
+ let (tx, rx) = std::sync::mpsc::channel::<Vec<pages::input::Finger>>();
+ tokio::spawn(async move {
+ let socket_path = "/tmp/clear-input-coords.sock";
+ loop {
+ if let Ok(stream) = tokio::net::UnixStream::connect(socket_path).await {
+ use tokio::io::AsyncBufReadExt;
+ let reader = tokio::io::BufReader::new(stream);
+ let mut lines = reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ if let Ok(fingers) = serde_json::from_str::<Vec<pages::input::Finger>>(&line) {
+ if tx.send(fingers).is_err() {
+ return;
+ }
+ }
+ }
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ }
+ });
+ rx
+ };
let rx_system = spawn_bg(5, || pages::system_info::fetch_system_state());
let rx_processors = spawn_bg(3, || pages::processors::fetch_processors_state());
let rx_status = spawn_bg(10, || pages::status::fetch_status_state());
@@ -342,10 +368,11 @@ impl SystemInterface {
};
let rx_backup_state = spawn_bg(30, || pages::backup::fetch_backup_state());
let rx_typeface = spawn_bg(30, || pages::typeface::fetch_typeface_state());
+ let rx_services = spawn_bg(3, || pages::services::fetch_services());
let (tx_backup, rx_backup) = std::sync::mpsc::channel();
let (tx_color_selector, rx_color_selector) = std::sync::mpsc::channel();
- let scale_factor = 2.0;
+ let scale_factor = scale;
let mut this = Self {
window, surface, wgpu_surface, device, queue, config, render_pipeline,
@@ -356,9 +383,9 @@ impl SystemInterface {
sidebar_width: 140.0, header_height: 0.0, status_height: 0.0,
cursor_x: 0.0, cursor_y: 0.0,
scale_factor,
- rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input,
+ rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input, rx_fingers,
rx_processors, rx_system, rx_status, rx_storage, rx_notifications,
- rx_backup_state, rx_typeface, tx_backup, rx_backup,
+ rx_backup_state, rx_typeface, rx_services, tx_backup, rx_backup,
tx_color_selector, rx_color_selector,
width, height,
needs_rebuild: true,
@@ -472,9 +499,43 @@ impl SystemInterface {
let buf = make_text_buffer(&mut self.font_system, &btn.label, btn.label_size * s);
let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
let lh = btn.label_size * s * 1.4;
+ let mut left_align = btn.left_align;
+
+ // Auto-detect if inside a ScrollBox to apply left alignment by default
+ if !left_align && btn.w >= 60.0 {
+ if self.app.current_page == Page::Typeface {
+ let sb = &self.app.typeface.list_box;
+ let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+ if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+ && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+ left_align = true;
+ }
+ } else if self.app.current_page == Page::Processors {
+ let sb = &self.app.processors.cpu_list_box;
+ let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+ if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+ && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+ left_align = true;
+ }
+ } else if self.app.current_page == Page::Services {
+ let sb = &self.app.services.list_box;
+ let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+ if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+ && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+ left_align = true;
+ }
+ }
+ }
+
+ let text_x = if left_align {
+ btn.x * s + 8.0 * s
+ } else {
+ btn.x * s + (btn.w * s - tw) / 2.0
+ };
+
text_items.push(TextItem {
buffer: buf,
- x: btn.x * s + (btn.w * s - tw) / 2.0, y: (btn.y - scroll_offset_y) * s + (btn.h * s - lh) / 2.0,
+ x: text_x, y: (btn.y - scroll_offset_y) * s + (btn.h * s - lh) / 2.0,
color: glyphon::Color::rgb(
(btn.label_color[0] * 255.0) as u8,
(btn.label_color[1] * 255.0) as u8,
@@ -508,6 +569,7 @@ impl SystemInterface {
Page::Notifications => notifications::view(&mut self.app.notifications, cx, cy, cw, ch),
Page::Backup => backup::view(&self.app.backup, cx, cy, cw, ch),
Page::Typeface => typeface::view(&mut self.app.typeface, cx, cy, cw, ch),
+ Page::Services => services::view(&mut self.app.services, cx, cy, cw, ch),
}
}
@@ -594,6 +656,14 @@ impl SystemInterface {
input::update(&mut self.app.input, input::InputMessage::Refreshed(s));
self.needs_rebuild = true;
}
+ let mut got_fingers = None;
+ while let Ok(s) = self.rx_fingers.try_recv() {
+ got_fingers = Some(s);
+ }
+ if let Some(fingers) = got_fingers {
+ input::update(&mut self.app.input, input::InputMessage::UpdateFingers(fingers));
+ self.needs_rebuild = true;
+ }
while let Ok(s) = self.rx_system.try_recv() {
system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s));
self.needs_rebuild = true;
@@ -622,6 +692,10 @@ impl SystemInterface {
typeface::update(&mut self.app.typeface, typeface::TypefaceMessage::Refreshed(s));
self.needs_rebuild = true;
}
+ while let Ok(s) = self.rx_services.try_recv() {
+ pages::services::update(&mut self.app.services, pages::services::ServicesMessage::Refreshed(s));
+ self.needs_rebuild = true;
+ }
while let Ok(m) = self.rx_backup.try_recv() {
self.handle_action(&AppAction::Backup(m));
self.needs_rebuild = true;
@@ -654,6 +728,7 @@ impl SystemInterface {
AppAction::Storage(m) => storage::update(&mut self.app.storage, m.clone()),
AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
AppAction::Typeface(m) => typeface::update(&mut self.app.typeface, m.clone()),
+ AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
AppAction::Backup(m) => match m {
pages::backup::BackupMessage::StartBackup => {
pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::StartBackup);
@@ -828,12 +903,21 @@ impl SystemInterface {
changed = true;
}
}
+ if self.app.current_page == Page::Services {
+ if self.app.services.search_box.cursor_moved(lx, ly) {
+ changed = true;
+ }
+ if self.app.services.list_box.cursor_moved(lx, ly) {
+ changed = true;
+ }
+ }
if changed { self.needs_rebuild = true; }
changed
}
fn handle_mouse_input(&mut self, button: clear_ui::widget::MouseButton, state: clear_ui::widget::ElementState) -> bool {
if button != clear_ui::widget::MouseButton::Left { return false; }
+ let s = self.scale_factor as f32;
if state == clear_ui::widget::ElementState::Released {
let (px, py) = (self.cursor_x, self.cursor_y);
for btn in &self.page_buttons.clone() {
@@ -856,7 +940,6 @@ impl SystemInterface {
}
}
}
- let s = self.scale_factor as f32;
let lx = self.cursor_x / s;
let ly = self.cursor_y / s + self.scroll_y;
let mut actions = Vec::new();
@@ -1115,6 +1198,13 @@ impl SystemInterface {
actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSearch(tb.text.clone())));
}
}
+ if state == clear_ui::widget::ElementState::Pressed && self.app.current_page == Page::Services {
+ let tb = &mut self.app.services.search_box;
+ if !tb.hit_test(lx, ly) { tb.unfocus(); }
+ if tb.mouse_input(button, state, lx, ly) {
+ self.needs_rebuild = true;
+ }
+ }
for a in &actions {
self.handle_action(a);
}
@@ -1139,6 +1229,13 @@ impl SystemInterface {
return true;
}
}
+ if self.app.current_page == Page::Services {
+ let srv = &mut self.app.services;
+ if srv.list_box.mouse_wheel(delta, lx, ly) {
+ self.needs_rebuild = true;
+ return true;
+ }
+ }
if self.app.current_page == Page::Processors {
let proc = &mut self.app.processors;
if proc.cpu_list_box.mouse_wheel(delta, lx, ly) {
@@ -1387,6 +1484,14 @@ impl SystemInterface {
return true;
}
}
+ if self.app.current_page == Page::Services {
+ let tb = &mut self.app.services.search_box;
+ if tb.keyboard_input(event) {
+ tb.take_change();
+ self.needs_rebuild = true;
+ return true;
+ }
+ }
false
}
@@ -1458,6 +1563,9 @@ struct App {
pointer: Option<wl_pointer::WlPointer>,
keyboard: Option<wl_keyboard::WlKeyboard>,
+ window: Option<XdgWindow>,
+ surface: Option<wl_surface::WlSurface>,
+
state: Option<SystemInterface>,
initial_page: Page,
exit: bool,
@@ -1474,9 +1582,15 @@ impl CompositorHandler for App {
_surface: &wl_surface::WlSurface,
scale_factor: i32,
) {
+ _surface.set_buffer_scale(scale_factor);
if let Some(state) = &mut self.state {
- state.scale_factor = (scale_factor as f32).max(2.0) as f64;
- state.resize(state.width, state.height);
+ let old_scale = state.scale_factor;
+ state.scale_factor = scale_factor as f64;
+ let logical_w = state.width as f64 / old_scale;
+ let logical_h = state.height as f64 / old_scale;
+ let pw = (logical_w * state.scale_factor) as u32;
+ let ph = (logical_h * state.scale_factor) as u32;
+ state.resize(pw, ph);
}
self.redraw = true;
}
@@ -1586,11 +1700,17 @@ impl PointerHandler for App {
) {
use smithay_client_toolkit::seat::pointer::PointerEventKind;
for event in events {
- let (x, y) = event.position;
+ if let Some(st) = &mut self.state {
+ let (cx, cy) = clear_ui::wayland::scale_pointer_pos(event.position, st.scale_factor);
+ st.cursor_x = cx;
+ st.cursor_y = cy;
+ }
match &event.kind {
PointerEventKind::Motion { .. } => {
if let Some(st) = &mut self.state {
- if st.handle_cursor_moved(x as f32, y as f32) {
+ let cx = st.cursor_x;
+ let cy = st.cursor_y;
+ if st.handle_cursor_moved(cx, cy) {
self.redraw = true;
}
}
@@ -1741,7 +1861,9 @@ impl WindowHandler for App {
let width = w.get();
let height = h.get();
if let Some(state) = &mut self.state {
- state.resize(width, height);
+ let pw = (width as f64 * state.scale_factor) as u32;
+ let ph = (height as f64 * state.scale_factor) as u32;
+ state.resize(pw, ph);
}
}
self.redraw = true;
@@ -1821,20 +1943,33 @@ fn main() {
seats: Vec::new(),
pointer: None,
keyboard: None,
+ window: None,
+ surface: None,
state: None,
initial_page,
exit: false,
redraw: true,
};
+ // Perform a roundtrip to populate output_state with active output scales
+ event_queue.roundtrip(&mut app).unwrap();
+
+ let scale = clear_ui::wayland::detect_scale_factor(&app.output_state);
+
+ let pw = (820.0 * scale) as u32;
+ let ph = (680.0 * scale) as u32;
+
let state = pollster::block_on(SystemInterface::new(
&conn,
&qh,
&app.compositor_state,
&app.xdg_shell_state,
- 820,
- 680,
+ pw,
+ ph,
+ scale,
));
+ app.window = Some(state.window.clone());
+ app.surface = Some(state.surface.clone());
app.state = Some(state);
let mut event_loop = EventLoop::try_new().unwrap();
@@ -1848,6 +1983,12 @@ fn main() {
if app.exit {
break;
}
+ if let Some(state) = &mut app.state {
+ state.poll_background_updates();
+ if state.needs_rebuild {
+ app.redraw = true;
+ }
+ }
if app.redraw {
app.redraw = false;
if let Some(state) = &mut app.state {
diff --git a/src/pages/input.rs b/src/pages/input.rs
index 3cade7f..47ca0d1 100644
--- a/src/pages/input.rs
+++ b/src/pages/input.rs
@@ -8,6 +8,13 @@ use clear_ui::widget::{Spinbox, Toggle};
const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct Finger {
+ pub slot: usize,
+ pub x: f32,
+ pub y: f32,
+}
+
#[derive(Debug, Clone)]
pub struct Keybind {
pub mods: String,
@@ -25,6 +32,7 @@ pub struct InputState {
pub delay_spinbox: Spinbox,
pub tap_toggle: Toggle,
pub keybinds: Vec<Keybind>,
+ pub fingers: Vec<Finger>,
// Inertial settings
pub inertial_scroll: bool,
@@ -52,6 +60,7 @@ impl Default for InputState {
delay_spinbox: Spinbox::new(300, 100, 2000, 10).with_label("Repeat Delay").with_unit("ms"),
tap_toggle: Toggle::new().with_label("Tap to Click"),
keybinds: Vec::new(),
+ fingers: Vec::new(),
inertial_scroll: true,
scroll_friction: 90,
@@ -75,6 +84,7 @@ pub enum InputMessage {
ToggleTapToClick,
ApplyRepeat,
Refreshed(InputState),
+ UpdateFingers(Vec<Finger>),
ToggleInertialScroll,
ApplyScrollFriction,
@@ -105,6 +115,7 @@ pub fn read_input_config() -> InputState {
delay_spinbox: Spinbox::new(delay as i32, 100, 2000, 10).with_label("Repeat Delay").with_unit("ms"),
tap_toggle: Toggle::new().with_label("Tap to Click"),
keybinds: parse_keybinds(&content),
+ fingers: Vec::new(),
inertial_scroll,
scroll_friction,
@@ -248,7 +259,42 @@ pub fn view(state: &mut InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Page
let toggle_h = 24.0;
state.tap_toggle.set_toggled(state.tap_to_click);
sec.widget(&mut pc, &mut state.tap_toggle, 14.0, toggle_w, toggle_h);
- sec.spacing(4.0);
+ sec.spacing(8.0);
+
+ // Centered trackpad visualizer box
+ let pad_w = 280.0;
+ let pad_h = 140.0;
+ let pad_x = sec.ax((cw - 16.0 - pad_w) / 2.0);
+ let pad_y = sec.ay();
+
+ // Background of trackpad: sleek dark translucent blue/grey
+ pc.rect([0.11, 0.11, 0.16, 0.85], pad_x, pad_y, pad_w, pad_h);
+
+ // Border: clean border
+ let border_color = [0.28, 0.28, 0.38, 1.0];
+ pc.rect(border_color, pad_x, pad_y, pad_w, 1.0);
+ pc.rect(border_color, pad_x, pad_y + pad_h - 1.0, pad_w, 1.0);
+ pc.rect(border_color, pad_x, pad_y, 1.0, pad_h);
+ pc.rect(border_color, pad_x + pad_w - 1.0, pad_y, 1.0, pad_h);
+
+ // Sleek label in the touchpad area
+ pc.text("Touchpad Area", pad_x + 12.0, pad_y + pad_h - 22.0, 11.0, [0.45, 0.45, 0.55, 1.0]);
+
+ // Active fingers visualizer
+ for finger in &state.fingers {
+ let rx = finger.x.clamp(0.0, 1.0);
+ let ry = finger.y.clamp(0.0, 1.0);
+ let fx = pad_x + rx * pad_w;
+ let fy = pad_y + ry * pad_h;
+ let dot_size = 12.0;
+
+ // Render glow (outer light blue rectangle)
+ pc.rect([0.35, 0.55, 0.95, 0.4], fx - (dot_size + 6.0) / 2.0, fy - (dot_size + 6.0) / 2.0, dot_size + 6.0, dot_size + 6.0);
+ // Render core (solid blue/purple rectangle)
+ pc.rect([0.45, 0.65, 1.0, 1.0], fx - dot_size / 2.0, fy - dot_size / 2.0, dot_size, dot_size);
+ }
+
+ sec.spacing(pad_h + 12.0);
y = sec.finish(&mut pc);
// ── Keyboard ──
@@ -352,7 +398,12 @@ pub fn update(state: &mut InputState, msg: InputMessage) {
write_config_value("trackpad_friction", &friction.to_string());
}
InputMessage::Refreshed(new) => {
+ let fingers = state.fingers.clone();
*state = new;
+ state.fingers = fingers;
+ }
+ InputMessage::UpdateFingers(fingers) => {
+ state.fingers = fingers;
}
}
}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 64e232d..80e3046 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -12,12 +12,14 @@ pub mod processors;
pub mod notifications;
pub mod backup;
pub mod typeface;
+pub mod services;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Power,
Audio,
Radios,
+ Services,
Storage,
Display,
Layout,
@@ -31,7 +33,7 @@ pub enum Page {
}
impl Page {
- pub const ALL: [Page; 13] = [
+ pub const ALL: [Page; 14] = [
Page::Audio,
Page::Backup,
Page::Display,
@@ -41,6 +43,7 @@ impl Page {
Page::Processors,
Page::Power,
Page::Radios,
+ Page::Services,
Page::Status,
Page::Storage,
Page::System,
@@ -52,6 +55,7 @@ impl Page {
Page::Power => "Power",
Page::Audio => "Audio",
Page::Radios => "Radios",
+ Page::Services => "Services",
Page::Storage => "Storage",
Page::Display => "Display",
Page::Layout => "Layout",
diff --git a/src/pages/services.rs b/src/pages/services.rs
new file mode 100644
index 0000000..91f2f7a
--- /dev/null
+++ b/src/pages/services.rs
@@ -0,0 +1,553 @@
+use crate::app::PageContent;
+use clear_ui::layout::Section;
+use clear_ui::widget::{Widget, TextLabel, ScrollBox};
+use clear_ui::widget::{ElementState, KeyEvent, MouseButton, Key, NamedKey};
+
+// ── TextBox Widget ──
+
+#[derive(Debug, Clone)]
+pub struct TextBox {
+ x: f32, y: f32, w: f32, h: f32,
+ pub text: String,
+ pub editing: bool,
+ pub edit_buffer: String,
+ hovered: bool,
+ just_changed: bool,
+ label: Option<String>,
+ row_x: f32,
+ row_w: f32,
+}
+
+impl TextBox {
+ pub fn new(text: String) -> Self {
+ Self {
+ x: 0.0, y: 0.0, w: 0.0, h: 0.0,
+ text,
+ editing: false,
+ edit_buffer: String::new(),
+ hovered: false,
+ just_changed: false,
+ label: None,
+ row_x: 0.0,
+ row_w: 0.0,
+ }
+ }
+
+ pub fn with_label(mut self, label: &str) -> Self {
+ self.label = Some(label.to_string());
+ self
+ }
+
+ pub fn set_label(&mut self, label: &str) {
+ self.label = Some(label.to_string());
+ }
+
+ pub fn take_change(&mut self) -> bool {
+ let changed = self.just_changed;
+ self.just_changed = false;
+ changed
+ }
+}
+
+impl Default for TextBox {
+ fn default() -> Self {
+ Self::new(String::new())
+ }
+}
+
+impl Widget for TextBox {
+ fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+ fn set_row_rect(&mut self, x: f32, w: f32) { self.row_x = x; self.row_w = w; }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn color(&self) -> [f32; 4] {
+ [0.10, 0.10, 0.16, 1.0]
+ }
+
+ fn hit_test(&self, px: f32, py: f32) -> bool {
+ let (x, y, w, h) = self.rect();
+ let hx = if self.row_w > 0.0 { self.row_x } else { x };
+ let hw = if self.row_w > 0.0 { self.row_w } else { w };
+ let (hy, hh) = if self.label.is_some() {
+ (y - 18.0, h + 18.0)
+ } else {
+ (y, h)
+ };
+ px >= hx && px <= hx + hw && py >= hy && py <= hy + hh
+ }
+
+ fn top_room(&self) -> f32 { if self.label.is_some() { 18.0 } else { 0.0 } }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, py);
+ was != self.hovered
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left { return false; }
+ if state != ElementState::Pressed { return false; }
+ if !self.hit_test(px, py) { return false; }
+ self.focus();
+ true
+ }
+
+ fn focus(&mut self) {
+ if !self.editing {
+ self.editing = true;
+ self.edit_buffer = self.text.clone();
+ }
+ }
+
+ fn unfocus(&mut self) {
+ if self.editing {
+ self.editing = false;
+ if self.text != self.edit_buffer {
+ self.text = self.edit_buffer.clone();
+ self.just_changed = true;
+ }
+ }
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+ if !self.editing { return false; }
+ if event.state != ElementState::Pressed { return false; }
+ match &event.logical_key {
+ Key::Named(NamedKey::Backspace) => {
+ self.edit_buffer.pop();
+ true
+ }
+ Key::Named(NamedKey::Enter) => {
+ self.text = self.edit_buffer.clone();
+ self.editing = false;
+ self.just_changed = true;
+ true
+ }
+ Key::Named(NamedKey::Escape) => {
+ self.editing = false;
+ true
+ }
+ _ => {
+ if let Some(text) = &event.text {
+ if !event.repeat {
+ for ch in text.chars() {
+ if ch.is_alphanumeric() || ch == ' ' || ch == '-' || ch == '_' || ch == '*' || ch == '.' || ch == '@' {
+ self.edit_buffer.push(ch);
+ }
+ }
+ }
+ }
+ true
+ }
+ }
+ }
+
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ None
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ if self.hovered {
+ let (hy, hh) = if self.label.is_some() {
+ (self.y - 18.0, self.h + 18.0)
+ } else {
+ (self.y, self.h)
+ };
+ let hx = if self.row_w > 0.0 { self.row_x } else { self.x };
+ let hw = if self.row_w > 0.0 { self.row_w } else { self.w };
+ quads.push((hx, hy, hw, hh, [1.0, 1.0, 1.0, 0.06]));
+ }
+ let bg_color = if self.editing {
+ [0.12, 0.12, 0.18, 1.0]
+ } else {
+ [0.08, 0.08, 0.12, 1.0]
+ };
+ let border_color = if self.editing {
+ [0.30, 0.50, 0.32, 1.0]
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
+ quads.push((self.x, self.y, self.w, self.h, border_color));
+ quads.push((self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, bg_color));
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+ if let Some(ref label) = self.label {
+ labels.push(TextLabel {
+ text: label.clone(),
+ x: self.x + 4.0,
+ y: self.y - 14.0,
+ font_size: 12.0,
+ color: [0x83, 0x83, 0x8a],
+ });
+ }
+ let val_text = if self.editing {
+ format!("{}|", self.edit_buffer)
+ } else {
+ self.text.clone()
+ };
+ labels.push(TextLabel {
+ text: val_text,
+ x: self.x + 8.0,
+ y: self.y + (self.h - 12.0) / 2.0,
+ font_size: 13.0,
+ color: if self.editing { [0xee, 0xee, 0xf5] } else { [0xcc, 0xcc, 0xd4] },
+ });
+ labels
+ }
+}
+
+// ── Service Types and Page State ──
+
+#[derive(Debug, Clone)]
+pub struct ServiceInfo {
+ pub name: String,
+ pub description: String,
+ pub active_state: String,
+ pub sub_state: String,
+ pub is_system: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ServiceTab {
+ System,
+ User,
+}
+
+impl Default for ServiceTab {
+ fn default() -> Self {
+ ServiceTab::System
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct ServicesState {
+ pub loaded: bool,
+ pub services: Vec<ServiceInfo>,
+ pub active_tab: ServiceTab,
+ pub search_box: TextBox,
+ pub list_box: ScrollBox,
+}
+
+impl Default for ServicesState {
+ fn default() -> Self {
+ Self {
+ loaded: false,
+ services: Vec::new(),
+ active_tab: ServiceTab::System,
+ search_box: TextBox::new(String::new()).with_label("Filter Services"),
+ list_box: ScrollBox::new(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum ServicesMessage {
+ Refreshed(Vec<ServiceInfo>),
+ SetTab(ServiceTab),
+ Start(String, bool),
+ Stop(String, bool),
+ Restart(String, bool),
+}
+
+// ── Background Fetching ──
+
+pub async fn fetch_services() -> Vec<ServiceInfo> {
+ let mut services = Vec::new();
+
+ // 1. Fetch system-level services
+ if let Ok(output) = tokio::process::Command::new("systemctl")
+ .args(["list-units", "--type=service", "--all", "--no-legend"])
+ .output()
+ .await
+ {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ for line in stdout.lines() {
+ if let Some(info) = parse_service_line(line, true) {
+ services.push(info);
+ }
+ }
+ }
+
+ // 2. Fetch user-level services
+ if let Ok(output) = tokio::process::Command::new("systemctl")
+ .args(["--user", "list-units", "--type=service", "--all", "--no-legend"])
+ .output()
+ .await
+ {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ for line in stdout.lines() {
+ if let Some(info) = parse_service_line(line, false) {
+ services.push(info);
+ }
+ }
+ }
+
+ // Sort alphabetically by name
+ services.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+ services
+}
+
+fn parse_service_line(line: &str, is_system: bool) -> Option<ServiceInfo> {
+ let cleaned = line.trim_start_matches('●').trim();
+ if cleaned.is_empty() {
+ return None;
+ }
+ let parts: Vec<&str> = cleaned.split_whitespace().collect();
+ if parts.len() >= 4 && parts[0].ends_with(".service") {
+ let name = parts[0].to_string();
+ let _load = parts[1];
+ let active_state = parts[2].to_string();
+ let sub_state = parts[3].to_string();
+ let description = parts[4..].join(" ");
+ Some(ServiceInfo {
+ name,
+ description,
+ active_state,
+ sub_state,
+ is_system,
+ })
+ } else {
+ None
+ }
+}
+
+fn service_action(name: &str, action: &str, is_system: bool) {
+ if is_system {
+ // System service needs root privilege, spawn via pkexec
+ let _ = tokio::process::Command::new("pkexec")
+ .args(["systemctl", action, name])
+ .spawn();
+ } else {
+ // User service does not need root
+ let _ = tokio::process::Command::new("systemctl")
+ .args(["--user", action, name])
+ .spawn();
+ }
+}
+
+// ── View & Update ──
+
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+
+pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+ let mut pc = PageContent::new();
+ let y = cy + 12.0;
+
+ let mut sec = Section::new(&mut pc, cx, y, cw, "Services");
+
+ if !state.loaded {
+ sec.text(&mut pc, "Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
+ sec.spacing(18.0);
+ } else {
+ // Tab header buttons: System Services, User Services
+ let tab_w = 140.0;
+ let tab_h = 28.0;
+ let tab_y = sec.ay();
+ let active_bg = [0.20, 0.40, 0.65, 0.4];
+ let inactive_bg = [0.10, 0.10, 0.16, 0.3];
+ let hover_bg = [0.20, 0.20, 0.25, 0.15];
+
+ pc.button(
+ "System Services",
+ cx + 12.0,
+ tab_y,
+ tab_w,
+ tab_h,
+ if state.active_tab == ServiceTab::System { active_bg } else { inactive_bg },
+ hover_bg,
+ [0.90, 0.90, 0.95, 1.0],
+ crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)),
+ );
+
+ pc.button(
+ "User Services",
+ cx + 12.0 + tab_w + 8.0,
+ tab_y,
+ tab_w,
+ tab_h,
+ if state.active_tab == ServiceTab::User { active_bg } else { inactive_bg },
+ hover_bg,
+ [0.90, 0.90, 0.95, 1.0],
+ crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)),
+ );
+ sec.content_y += tab_h + 12.0;
+
+ // Search textbox
+ let search_y = sec.ay() + state.search_box.top_room();
+ let search_w = cw - 24.0;
+ let search_h = 28.0;
+
+ state.search_box.set_row_rect(cx + 12.0, search_w);
+ clear_ui::layout::render_widget(
+ &mut pc,
+ &mut state.search_box,
+ cx + 12.0,
+ search_y,
+ search_w,
+ search_h,
+ );
+ sec.content_y += search_h + state.search_box.top_room() + 16.0;
+
+ // Scroll box list
+ let list_box_x = cx + 12.0;
+ let list_box_y = sec.ay();
+ let list_box_w = cw - 24.0;
+ let list_box_h = 360.0;
+
+ clear_ui::layout::render_widget(&mut pc, &mut state.list_box, list_box_x, list_box_y, list_box_w, list_box_h);
+
+ // Filter services
+ let query = if state.search_box.editing {
+ state.search_box.edit_buffer.to_lowercase()
+ } else {
+ state.search_box.text.to_lowercase()
+ };
+ let filtered_services: Vec<&ServiceInfo> = state.services.iter()
+ .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
+ .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
+ .collect();
+
+ let item_h = 36.0;
+ let item_gap = 6.0;
+ let item_height_full = item_h + item_gap;
+ let content_h = filtered_services.len() as f32 * item_height_full;
+
+ state.list_box.update_bounds(content_h, list_box_y, list_box_h);
+
+ for (idx, service) in filtered_services.iter().enumerate() {
+ let virtual_y = idx as f32 * item_height_full + 4.0;
+ if let Some(draw_y) = state.list_box.get_item_draw_y(virtual_y, item_h) {
+ // Item background
+ let bg_color = [0.08, 0.08, 0.12, 0.2];
+ pc.rect(bg_color, list_box_x + 4.0, draw_y, list_box_w - 24.0, item_h);
+
+ // Status indicator color
+ let is_active = service.active_state == "active" || service.sub_state == "running";
+ let status_color = if service.active_state == "failed" {
+ [0.85, 0.25, 0.25, 1.0] // failed = red
+ } else if is_active {
+ [0.25, 0.75, 0.35, 1.0] // active = green
+ } else {
+ [0.55, 0.55, 0.60, 1.0] // inactive/dead = gray
+ };
+
+ // Render status dot (small square)
+ pc.rect(status_color, list_box_x + 14.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0);
+
+ // Service Name
+ pc.text(&service.name, list_box_x + 32.0, draw_y + 4.0, 13.0, [0.90, 0.90, 0.95, 1.0]);
+
+ // Service Description
+ let desc = if service.description.is_empty() { "No description" } else { &service.description };
+ let desc_truncated = if desc.len() > 65 { format!("{}...", &desc[..62]) } else { desc.to_string() };
+ pc.text(&desc_truncated, list_box_x + 32.0, draw_y + 19.0, 11.0, [0.55, 0.55, 0.60, 1.0]);
+
+ // Control buttons: Start, Stop, Restart on the right
+ let btn_w = 46.0;
+ let r_btn_w = 54.0;
+ let btn_gap = 6.0;
+ 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 btn_y = draw_y + (item_h - 22.0) / 2.0;
+ let btn_h = 22.0;
+
+ let active_txt = [0.90, 0.90, 0.95, 1.0];
+ let disabled_txt = [0.40, 0.40, 0.45, 1.0];
+
+ // Start button
+ pc.button(
+ "Start",
+ 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 },
+ crate::app::AppAction::Services(ServicesMessage::Start(service.name.clone(), service.is_system)),
+ );
+
+ // Stop button
+ pc.button(
+ "Stop",
+ stop_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 },
+ crate::app::AppAction::Services(ServicesMessage::Stop(service.name.clone(), service.is_system)),
+ );
+
+ // Restart button
+ pc.button(
+ "Restart",
+ restart_x,
+ btn_y,
+ r_btn_w,
+ btn_h,
+ [0.15, 0.28, 0.45, 0.3],
+ [0.20, 0.38, 0.58, 0.5],
+ active_txt,
+ crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
+ );
+ }
+ }
+
+ if filtered_services.is_empty() {
+ pc.text("No services match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+ }
+
+ sec.content_y += list_box_h;
+ }
+
+ sec.finish(&mut pc);
+ pc
+}
+
+pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
+ match msg {
+ ServicesMessage::Refreshed(new_services) => {
+ state.loaded = true;
+ state.services = new_services;
+ }
+ ServicesMessage::SetTab(tab) => {
+ state.active_tab = tab;
+ state.list_box.scroll_y = 0.0;
+ }
+ 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();
+ }
+ 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();
+ }
+ 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();
+ }
+ service_action(&name, "restart", is_system);
+ }
+ }
+}