initial commit: configuration GUI for gar desktop
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
a6b7cdc6d147839da863f26f2df5dfb0a003f2a1- Tree
fc88a40
a6b7cdc
a6b7cdc6d147839da863f26f2df5dfb0a003f2a1fc88a40.gitignoreadded@@ -0,0 +1,15 @@ | ||
| 1 | +# Build artifacts | |
| 2 | +/target/ | |
| 3 | +**/*.rs.bk | |
| 4 | +Cargo.lock | |
| 5 | + | |
| 6 | +# IDE | |
| 7 | +.idea/ | |
| 8 | +.vscode/ | |
| 9 | +*.swp | |
| 10 | +*.swo | |
| 11 | +*~ | |
| 12 | + | |
| 13 | +# Planning docs (local only) | |
| 14 | +/docs/roadmap.md | |
| 15 | +/docs/sprints/ | |
Cargo.tomladded@@ -0,0 +1,39 @@ | ||
| 1 | +[workspace] | |
| 2 | +resolver = "2" | |
| 3 | +members = ["gargears", "gargearsctl", "gargears-ipc"] | |
| 4 | + | |
| 5 | +[workspace.package] | |
| 6 | +version = "0.1.0" | |
| 7 | +edition = "2024" | |
| 8 | +license = "MIT" | |
| 9 | +authors = ["mfwolffe"] | |
| 10 | + | |
| 11 | +[workspace.dependencies] | |
| 12 | +# Internal crates | |
| 13 | +gargears-ipc = { path = "gargears-ipc" } | |
| 14 | +gartk-core = { path = "../gartk/gartk-core" } | |
| 15 | +gartk-x11 = { path = "../gartk/gartk-x11" } | |
| 16 | +gartk-render = { path = "../gartk/gartk-render" } | |
| 17 | + | |
| 18 | +# CLI | |
| 19 | +clap = { version = "4.5", features = ["derive"] } | |
| 20 | + | |
| 21 | +# Async | |
| 22 | +tokio = { version = "1.0", features = ["full"] } | |
| 23 | + | |
| 24 | +# Serialization | |
| 25 | +serde = { version = "1.0", features = ["derive"] } | |
| 26 | +serde_json = "1.0" | |
| 27 | +toml = "0.8" | |
| 28 | +regex = "1.11" | |
| 29 | + | |
| 30 | +# Error handling | |
| 31 | +thiserror = "2.0" | |
| 32 | +anyhow = "1.0" | |
| 33 | + | |
| 34 | +# Logging | |
| 35 | +tracing = "0.1" | |
| 36 | +tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 37 | + | |
| 38 | +# X11 for blitting | |
| 39 | +x11rb = { version = "0.13", features = ["allow-unsafe-code"] } | |
docs/.fackr/workspace.jsonadded@@ -0,0 +1,29 @@ | ||
| 1 | +{ | |
| 2 | + "active_tab": 0, | |
| 3 | + "tabs": [ | |
| 4 | + { | |
| 5 | + "files": [ | |
| 6 | + { | |
| 7 | + "path": "gargears.md", | |
| 8 | + "is_orphan": false | |
| 9 | + } | |
| 10 | + ], | |
| 11 | + "active_pane": 0, | |
| 12 | + "panes": [ | |
| 13 | + { | |
| 14 | + "buffer_idx": 0, | |
| 15 | + "cursor_line": 2, | |
| 16 | + "cursor_col": 68, | |
| 17 | + "viewport_line": 0, | |
| 18 | + "viewport_col": 0, | |
| 19 | + "bounds": { | |
| 20 | + "x_start": 0.0, | |
| 21 | + "y_start": 0.0, | |
| 22 | + "x_end": 1.0, | |
| 23 | + "y_end": 1.0 | |
| 24 | + } | |
| 25 | + } | |
| 26 | + ] | |
| 27 | + } | |
| 28 | + ] | |
| 29 | +} | |
docs/gargears.mdadded@@ -0,0 +1,3 @@ | ||
| 1 | +# gargears | |
| 2 | + | |
| 3 | +a centralized gui for controlling modules of the gar desktop suite. | |
gargears-ipc/Cargo.tomladded@@ -0,0 +1,12 @@ | ||
| 1 | +[package] | |
| 2 | +name = "gargears-ipc" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +authors.workspace = true | |
| 7 | +description = "IPC protocol types for gargears" | |
| 8 | + | |
| 9 | +[dependencies] | |
| 10 | +serde.workspace = true | |
| 11 | +serde_json.workspace = true | |
| 12 | +thiserror.workspace = true | |
gargears-ipc/src/lib.rsadded@@ -0,0 +1,73 @@ | ||
| 1 | +//! IPC protocol types for gargears | |
| 2 | +//! | |
| 3 | +//! This crate defines the IPC protocol used by gargears daemon and gargearsctl. | |
| 4 | + | |
| 5 | +use serde::{Deserialize, Serialize}; | |
| 6 | + | |
| 7 | +/// Commands that can be sent to the gargears daemon | |
| 8 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 9 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 10 | +pub enum Command { | |
| 11 | + /// Show the gargears window | |
| 12 | + Show, | |
| 13 | + /// Hide the gargears window | |
| 14 | + Hide, | |
| 15 | + /// Toggle window visibility | |
| 16 | + Toggle, | |
| 17 | + /// Get daemon status | |
| 18 | + Status, | |
| 19 | + /// Quit the daemon | |
| 20 | + Quit, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// Response from the gargears daemon | |
| 24 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 25 | +pub struct Response { | |
| 26 | + pub success: bool, | |
| 27 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 28 | + pub data: Option<serde_json::Value>, | |
| 29 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 30 | + pub error: Option<String>, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl Response { | |
| 34 | + pub fn ok() -> Self { | |
| 35 | + Self { | |
| 36 | + success: true, | |
| 37 | + data: None, | |
| 38 | + error: None, | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + pub fn ok_with_data(data: serde_json::Value) -> Self { | |
| 43 | + Self { | |
| 44 | + success: true, | |
| 45 | + data: Some(data), | |
| 46 | + error: None, | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + pub fn error(msg: impl Into<String>) -> Self { | |
| 51 | + Self { | |
| 52 | + success: false, | |
| 53 | + data: None, | |
| 54 | + error: Some(msg.into()), | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// Status information returned by the daemon | |
| 60 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 61 | +pub struct DaemonStatus { | |
| 62 | + pub visible: bool, | |
| 63 | + pub running: bool, | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Get the default socket path for gargears | |
| 67 | +pub fn socket_path() -> std::path::PathBuf { | |
| 68 | + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { | |
| 69 | + std::path::PathBuf::from(runtime_dir).join("gargears.sock") | |
| 70 | + } else { | |
| 71 | + std::path::PathBuf::from("/tmp/gargears.sock") | |
| 72 | + } | |
| 73 | +} | |
gargears/Cargo.tomladded@@ -0,0 +1,29 @@ | ||
| 1 | +[package] | |
| 2 | +name = "gargears" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +authors.workspace = true | |
| 7 | +description = "Centralized GUI for gardesk configuration" | |
| 8 | + | |
| 9 | +[[bin]] | |
| 10 | +name = "gargears" | |
| 11 | +path = "src/main.rs" | |
| 12 | + | |
| 13 | +[dependencies] | |
| 14 | +gargears-ipc.workspace = true | |
| 15 | +gartk-core.workspace = true | |
| 16 | +gartk-x11.workspace = true | |
| 17 | +gartk-render.workspace = true | |
| 18 | + | |
| 19 | +clap.workspace = true | |
| 20 | +tokio.workspace = true | |
| 21 | +serde.workspace = true | |
| 22 | +serde_json.workspace = true | |
| 23 | +toml.workspace = true | |
| 24 | +regex.workspace = true | |
| 25 | +thiserror.workspace = true | |
| 26 | +anyhow.workspace = true | |
| 27 | +tracing.workspace = true | |
| 28 | +tracing-subscriber.workspace = true | |
| 29 | +x11rb.workspace = true | |
gargears/src/app.rsadded@@ -0,0 +1,933 @@ | ||
| 1 | +//! Main application state and event loop | |
| 2 | + | |
| 3 | +use crate::daemon::{check_command, DaemonCommand}; | |
| 4 | +use crate::ipc::discovery::DaemonStatus; | |
| 5 | +use crate::ipc::{ConnectionManager, IpcEvent}; | |
| 6 | +use crate::panels::{ | |
| 7 | + Component, GarPanel, GarbarPanel, GarbgPanel, GarclipPanel, GarfieldPanel, GarlaunchPanel, | |
| 8 | + GarlockPanel, GarnotifyPanel, GarshotPanel, GartermPanel, GartrayPanel, | |
| 9 | +}; | |
| 10 | +use crate::ui::{Layout, Sidebar}; | |
| 11 | +use crate::ui::{Panel, PanelAction}; | |
| 12 | +use anyhow::Result; | |
| 13 | +use gartk_core::{InputEvent, Key, Point, Rect, Theme}; | |
| 14 | +use gartk_render::Renderer; | |
| 15 | +use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig}; | |
| 16 | +use std::collections::HashMap; | |
| 17 | +use std::sync::atomic::{AtomicBool, Ordering}; | |
| 18 | +use std::sync::mpsc::Receiver; | |
| 19 | +use std::sync::Arc; | |
| 20 | +use std::time::{Duration, Instant}; | |
| 21 | +use x11rb::protocol::xproto::{ConnectionExt, ImageFormat}; | |
| 22 | + | |
| 23 | +/// How often to refresh connections (in seconds) | |
| 24 | +const REFRESH_INTERVAL_SECS: u64 = 5; | |
| 25 | + | |
| 26 | +/// How often to poll IPC events (in milliseconds) | |
| 27 | +const IPC_POLL_INTERVAL_MS: u64 = 100; | |
| 28 | + | |
| 29 | +/// Main application state | |
| 30 | +pub struct App { | |
| 31 | + window: Window, | |
| 32 | + renderer: Renderer, | |
| 33 | + theme: Theme, | |
| 34 | + gc: u32, | |
| 35 | + sidebar: Sidebar, | |
| 36 | + layout: Layout, | |
| 37 | + selected_component: Component, | |
| 38 | + panels: HashMap<Component, Box<dyn Panel>>, | |
| 39 | + connection_manager: ConnectionManager, | |
| 40 | + daemon_status: Vec<DaemonStatus>, | |
| 41 | + last_refresh: Instant, | |
| 42 | + last_ipc_poll: Instant, | |
| 43 | + status_message: Option<(String, Instant)>, | |
| 44 | + should_quit: bool, | |
| 45 | + visible: Arc<AtomicBool>, | |
| 46 | + instant_apply: bool, | |
| 47 | + /// Persistent back buffer for blitting (avoids per-frame allocation) | |
| 48 | + blit_buffer: Option<gartk_render::Surface>, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Duration to show status messages before fading | |
| 52 | +const STATUS_MESSAGE_DURATION: Duration = Duration::from_secs(3); | |
| 53 | + | |
| 54 | +impl App { | |
| 55 | + /// Create a new application | |
| 56 | + pub fn new(initial_panel: Option<&str>) -> Result<Self> { | |
| 57 | + // Connect to X11 | |
| 58 | + let conn = Connection::connect(None)?; | |
| 59 | + | |
| 60 | + // Get monitor at pointer for centering | |
| 61 | + let monitor = gartk_x11::monitor_at_pointer(&conn)?; | |
| 62 | + | |
| 63 | + // Window size | |
| 64 | + let width = 900; | |
| 65 | + let height = 600; | |
| 66 | + let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2; | |
| 67 | + let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2; | |
| 68 | + | |
| 69 | + // Create window | |
| 70 | + let window = Window::create( | |
| 71 | + conn.clone(), | |
| 72 | + WindowConfig::new() | |
| 73 | + .title("gargears") | |
| 74 | + .class("gargears") | |
| 75 | + .position(x, y) | |
| 76 | + .size(width, height) | |
| 77 | + .transparent(true), | |
| 78 | + )?; | |
| 79 | + | |
| 80 | + window.focus()?; | |
| 81 | + | |
| 82 | + // Create renderer | |
| 83 | + let theme = Theme::dark(); | |
| 84 | + let renderer = Renderer::with_theme(width, height, theme.clone())?; | |
| 85 | + | |
| 86 | + // Create GC for blitting | |
| 87 | + let gc = conn.generate_id()?; | |
| 88 | + conn.inner() | |
| 89 | + .create_gc(gc, window.id(), &Default::default())?; | |
| 90 | + conn.flush()?; | |
| 91 | + | |
| 92 | + // Initialize components | |
| 93 | + let sidebar = Sidebar::new(); | |
| 94 | + let layout = Layout::new(width, height); | |
| 95 | + | |
| 96 | + // Parse initial panel | |
| 97 | + let selected_component = initial_panel | |
| 98 | + .and_then(|name| Component::from_name(name)) | |
| 99 | + .unwrap_or(Component::Gar); | |
| 100 | + | |
| 101 | + // Initialize connection manager and connect to running daemons | |
| 102 | + let mut connection_manager = ConnectionManager::new(); | |
| 103 | + let daemon_status = connection_manager.connect_all(); | |
| 104 | + | |
| 105 | + // Subscribe to events | |
| 106 | + connection_manager.subscribe_to_events(); | |
| 107 | + | |
| 108 | + // Create panels | |
| 109 | + let mut panels: HashMap<Component, Box<dyn Panel>> = HashMap::new(); | |
| 110 | + | |
| 111 | + // Create all panels | |
| 112 | + panels.insert( | |
| 113 | + Component::Gar, | |
| 114 | + Box::new(GarPanel::new(connection_manager.take_gar_adapter())), | |
| 115 | + ); | |
| 116 | + panels.insert( | |
| 117 | + Component::Garbar, | |
| 118 | + Box::new(GarbarPanel::new(connection_manager.take_garbar_adapter())), | |
| 119 | + ); | |
| 120 | + panels.insert( | |
| 121 | + Component::Garbg, | |
| 122 | + Box::new(GarbgPanel::new(connection_manager.take_garbg_adapter())), | |
| 123 | + ); | |
| 124 | + panels.insert( | |
| 125 | + Component::Garterm, | |
| 126 | + Box::new(GartermPanel::new(connection_manager.take_garterm_adapter())), | |
| 127 | + ); | |
| 128 | + panels.insert( | |
| 129 | + Component::Gartray, | |
| 130 | + Box::new(GartrayPanel::new(connection_manager.take_gartray_adapter())), | |
| 131 | + ); | |
| 132 | + panels.insert( | |
| 133 | + Component::Garshot, | |
| 134 | + Box::new(GarshotPanel::new(connection_manager.take_garshot_adapter())), | |
| 135 | + ); | |
| 136 | + panels.insert( | |
| 137 | + Component::Garlock, | |
| 138 | + Box::new(GarlockPanel::new(connection_manager.take_garlock_adapter())), | |
| 139 | + ); | |
| 140 | + panels.insert( | |
| 141 | + Component::Garfield, | |
| 142 | + Box::new(GarfieldPanel::new(connection_manager.take_garfield_adapter())), | |
| 143 | + ); | |
| 144 | + panels.insert( | |
| 145 | + Component::Garclip, | |
| 146 | + Box::new(GarclipPanel::new(connection_manager.take_garclip_adapter())), | |
| 147 | + ); | |
| 148 | + panels.insert( | |
| 149 | + Component::Garlaunch, | |
| 150 | + Box::new(GarlaunchPanel::new(connection_manager.take_garlaunch_adapter())), | |
| 151 | + ); | |
| 152 | + panels.insert( | |
| 153 | + Component::Garnotify, | |
| 154 | + Box::new(GarnotifyPanel::new(connection_manager.take_garnotify_adapter())), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + Ok(Self { | |
| 158 | + window, | |
| 159 | + renderer, | |
| 160 | + theme, | |
| 161 | + gc, | |
| 162 | + sidebar, | |
| 163 | + layout, | |
| 164 | + selected_component, | |
| 165 | + panels, | |
| 166 | + connection_manager, | |
| 167 | + daemon_status, | |
| 168 | + last_refresh: Instant::now(), | |
| 169 | + last_ipc_poll: Instant::now(), | |
| 170 | + status_message: None, | |
| 171 | + should_quit: false, | |
| 172 | + visible: Arc::new(AtomicBool::new(true)), | |
| 173 | + instant_apply: true, // Enable instant-apply by default | |
| 174 | + blit_buffer: None, | |
| 175 | + }) | |
| 176 | + } | |
| 177 | + | |
| 178 | + /// Create a new application for daemon mode (starts hidden) | |
| 179 | + pub fn new_daemon(initial_panel: Option<&str>) -> Result<Self> { | |
| 180 | + // Connect to X11 | |
| 181 | + let conn = Connection::connect(None)?; | |
| 182 | + | |
| 183 | + // Get monitor at pointer for centering | |
| 184 | + let monitor = gartk_x11::monitor_at_pointer(&conn)?; | |
| 185 | + | |
| 186 | + // Window size | |
| 187 | + let width = 900; | |
| 188 | + let height = 600; | |
| 189 | + let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2; | |
| 190 | + let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2; | |
| 191 | + | |
| 192 | + // Create window but don't map it yet | |
| 193 | + let window = Window::create( | |
| 194 | + conn.clone(), | |
| 195 | + WindowConfig::new() | |
| 196 | + .title("gargears") | |
| 197 | + .class("gargears") | |
| 198 | + .position(x, y) | |
| 199 | + .size(width, height) | |
| 200 | + .transparent(true) | |
| 201 | + .map_on_create(false), // Don't show immediately | |
| 202 | + )?; | |
| 203 | + | |
| 204 | + // Create renderer | |
| 205 | + let theme = Theme::dark(); | |
| 206 | + let renderer = Renderer::with_theme(width, height, theme.clone())?; | |
| 207 | + | |
| 208 | + // Create GC for blitting | |
| 209 | + let gc = conn.generate_id()?; | |
| 210 | + conn.inner() | |
| 211 | + .create_gc(gc, window.id(), &Default::default())?; | |
| 212 | + conn.flush()?; | |
| 213 | + | |
| 214 | + // Initialize components | |
| 215 | + let sidebar = Sidebar::new(); | |
| 216 | + let layout = Layout::new(width, height); | |
| 217 | + | |
| 218 | + // Parse initial panel | |
| 219 | + let selected_component = initial_panel | |
| 220 | + .and_then(|name| Component::from_name(name)) | |
| 221 | + .unwrap_or(Component::Gar); | |
| 222 | + | |
| 223 | + // Initialize connection manager | |
| 224 | + let mut connection_manager = ConnectionManager::new(); | |
| 225 | + let daemon_status = connection_manager.connect_all(); | |
| 226 | + connection_manager.subscribe_to_events(); | |
| 227 | + | |
| 228 | + // Create panels | |
| 229 | + let mut panels: HashMap<Component, Box<dyn Panel>> = HashMap::new(); | |
| 230 | + panels.insert( | |
| 231 | + Component::Gar, | |
| 232 | + Box::new(GarPanel::new(connection_manager.take_gar_adapter())), | |
| 233 | + ); | |
| 234 | + panels.insert( | |
| 235 | + Component::Garbar, | |
| 236 | + Box::new(GarbarPanel::new(connection_manager.take_garbar_adapter())), | |
| 237 | + ); | |
| 238 | + panels.insert( | |
| 239 | + Component::Garbg, | |
| 240 | + Box::new(GarbgPanel::new(connection_manager.take_garbg_adapter())), | |
| 241 | + ); | |
| 242 | + panels.insert( | |
| 243 | + Component::Garterm, | |
| 244 | + Box::new(GartermPanel::new(connection_manager.take_garterm_adapter())), | |
| 245 | + ); | |
| 246 | + panels.insert( | |
| 247 | + Component::Gartray, | |
| 248 | + Box::new(GartrayPanel::new(connection_manager.take_gartray_adapter())), | |
| 249 | + ); | |
| 250 | + panels.insert( | |
| 251 | + Component::Garshot, | |
| 252 | + Box::new(GarshotPanel::new(connection_manager.take_garshot_adapter())), | |
| 253 | + ); | |
| 254 | + panels.insert( | |
| 255 | + Component::Garlock, | |
| 256 | + Box::new(GarlockPanel::new(connection_manager.take_garlock_adapter())), | |
| 257 | + ); | |
| 258 | + panels.insert( | |
| 259 | + Component::Garfield, | |
| 260 | + Box::new(GarfieldPanel::new(connection_manager.take_garfield_adapter())), | |
| 261 | + ); | |
| 262 | + panels.insert( | |
| 263 | + Component::Garclip, | |
| 264 | + Box::new(GarclipPanel::new(connection_manager.take_garclip_adapter())), | |
| 265 | + ); | |
| 266 | + panels.insert( | |
| 267 | + Component::Garlaunch, | |
| 268 | + Box::new(GarlaunchPanel::new(connection_manager.take_garlaunch_adapter())), | |
| 269 | + ); | |
| 270 | + panels.insert( | |
| 271 | + Component::Garnotify, | |
| 272 | + Box::new(GarnotifyPanel::new(connection_manager.take_garnotify_adapter())), | |
| 273 | + ); | |
| 274 | + | |
| 275 | + Ok(Self { | |
| 276 | + window, | |
| 277 | + renderer, | |
| 278 | + theme, | |
| 279 | + gc, | |
| 280 | + sidebar, | |
| 281 | + layout, | |
| 282 | + selected_component, | |
| 283 | + panels, | |
| 284 | + connection_manager, | |
| 285 | + daemon_status, | |
| 286 | + last_refresh: Instant::now(), | |
| 287 | + last_ipc_poll: Instant::now(), | |
| 288 | + status_message: None, | |
| 289 | + should_quit: false, | |
| 290 | + visible: Arc::new(AtomicBool::new(false)), // Start hidden | |
| 291 | + instant_apply: true, // Enable instant-apply by default | |
| 292 | + blit_buffer: None, | |
| 293 | + }) | |
| 294 | + } | |
| 295 | + | |
| 296 | + /// Get visibility state (for IPC server) | |
| 297 | + pub fn visible_state(&self) -> Arc<AtomicBool> { | |
| 298 | + Arc::clone(&self.visible) | |
| 299 | + } | |
| 300 | + | |
| 301 | + /// Show the window | |
| 302 | + pub fn show(&mut self) -> Result<()> { | |
| 303 | + if !self.visible.load(Ordering::Relaxed) { | |
| 304 | + self.window.map()?; | |
| 305 | + self.window.focus()?; | |
| 306 | + self.visible.store(true, Ordering::Relaxed); | |
| 307 | + tracing::info!("Window shown"); | |
| 308 | + } | |
| 309 | + Ok(()) | |
| 310 | + } | |
| 311 | + | |
| 312 | + /// Hide the window | |
| 313 | + pub fn hide(&mut self) -> Result<()> { | |
| 314 | + if self.visible.load(Ordering::Relaxed) { | |
| 315 | + self.window.unmap()?; | |
| 316 | + self.visible.store(false, Ordering::Relaxed); | |
| 317 | + tracing::info!("Window hidden"); | |
| 318 | + } | |
| 319 | + Ok(()) | |
| 320 | + } | |
| 321 | + | |
| 322 | + /// Toggle window visibility | |
| 323 | + pub fn toggle(&mut self) -> Result<()> { | |
| 324 | + if self.visible.load(Ordering::Relaxed) { | |
| 325 | + self.hide() | |
| 326 | + } else { | |
| 327 | + self.show() | |
| 328 | + } | |
| 329 | + } | |
| 330 | + | |
| 331 | + /// Run the application event loop | |
| 332 | + pub fn run(&mut self) -> Result<()> { | |
| 333 | + let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?; | |
| 334 | + | |
| 335 | + // Initial render | |
| 336 | + self.render()?; | |
| 337 | + | |
| 338 | + event_loop.run(|ev, event| { | |
| 339 | + match event.clone() { | |
| 340 | + InputEvent::Key(key_event) if key_event.pressed => { | |
| 341 | + // First check global keys | |
| 342 | + if self.handle_key(&key_event.key) { | |
| 343 | + ev.request_redraw(); | |
| 344 | + } else { | |
| 345 | + // Pass to current panel | |
| 346 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 347 | + match panel.handle_event(&event) { | |
| 348 | + PanelAction::Apply => { | |
| 349 | + if let Err(e) = panel.apply() { | |
| 350 | + self.set_status(format!("Apply failed: {}", e)); | |
| 351 | + } else { | |
| 352 | + self.set_status("Changes applied"); | |
| 353 | + } | |
| 354 | + } | |
| 355 | + PanelAction::InstantApply => { | |
| 356 | + if let Err(e) = panel.apply_instant() { | |
| 357 | + self.set_status(format!("Apply failed: {}", e)); | |
| 358 | + } | |
| 359 | + } | |
| 360 | + PanelAction::Reset => { | |
| 361 | + panel.reset(); | |
| 362 | + self.set_status("Reset to original values"); | |
| 363 | + } | |
| 364 | + PanelAction::Save => { | |
| 365 | + if panel.has_config_file() { | |
| 366 | + if let Err(e) = panel.save_to_config() { | |
| 367 | + self.set_status(format!("Save failed: {}", e)); | |
| 368 | + } else { | |
| 369 | + self.set_status("Saved to config file"); | |
| 370 | + } | |
| 371 | + } else { | |
| 372 | + self.set_status("No config file for this component"); | |
| 373 | + } | |
| 374 | + } | |
| 375 | + PanelAction::Redraw | PanelAction::None => {} | |
| 376 | + } | |
| 377 | + ev.request_redraw(); | |
| 378 | + } | |
| 379 | + } | |
| 380 | + } | |
| 381 | + InputEvent::MousePress(mouse_event) => { | |
| 382 | + let x = mouse_event.position.x; | |
| 383 | + let y = mouse_event.position.y; | |
| 384 | + | |
| 385 | + // Check if click is in sidebar | |
| 386 | + let sidebar_bounds = self.layout.sidebar_bounds(); | |
| 387 | + if x >= sidebar_bounds.x | |
| 388 | + && x < sidebar_bounds.x + sidebar_bounds.width as i32 | |
| 389 | + && y >= sidebar_bounds.y | |
| 390 | + && y < sidebar_bounds.y + sidebar_bounds.height as i32 | |
| 391 | + { | |
| 392 | + if let Some(component) = self.sidebar.component_at_y(y, &self.layout, &self.theme) { | |
| 393 | + self.selected_component = component; | |
| 394 | + } | |
| 395 | + } else { | |
| 396 | + // Pass to current panel | |
| 397 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 398 | + match panel.handle_event(&event) { | |
| 399 | + PanelAction::Apply => { | |
| 400 | + if let Err(e) = panel.apply() { | |
| 401 | + self.set_status(format!("Apply failed: {}", e)); | |
| 402 | + } else { | |
| 403 | + self.set_status("Changes applied"); | |
| 404 | + } | |
| 405 | + } | |
| 406 | + PanelAction::InstantApply => { | |
| 407 | + if let Err(e) = panel.apply_instant() { | |
| 408 | + self.set_status(format!("Apply failed: {}", e)); | |
| 409 | + } | |
| 410 | + } | |
| 411 | + PanelAction::Reset => { | |
| 412 | + panel.reset(); | |
| 413 | + self.set_status("Reset to original values"); | |
| 414 | + } | |
| 415 | + PanelAction::Save => { | |
| 416 | + if panel.has_config_file() { | |
| 417 | + if let Err(e) = panel.save_to_config() { | |
| 418 | + self.set_status(format!("Save failed: {}", e)); | |
| 419 | + } else { | |
| 420 | + self.set_status("Saved to config file"); | |
| 421 | + } | |
| 422 | + } else { | |
| 423 | + self.set_status("No config file for this component"); | |
| 424 | + } | |
| 425 | + } | |
| 426 | + PanelAction::Redraw | PanelAction::None => {} | |
| 427 | + } | |
| 428 | + } | |
| 429 | + } | |
| 430 | + ev.request_redraw(); | |
| 431 | + } | |
| 432 | + InputEvent::MouseMove(mouse_event) => { | |
| 433 | + // Update hover states - only redraw if needed | |
| 434 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 435 | + if panel.on_mouse_move(mouse_event.position.x, mouse_event.position.y) { | |
| 436 | + ev.request_redraw(); | |
| 437 | + } | |
| 438 | + } | |
| 439 | + } | |
| 440 | + InputEvent::Scroll(_) => { | |
| 441 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 442 | + panel.handle_event(&event); | |
| 443 | + } | |
| 444 | + ev.request_redraw(); | |
| 445 | + } | |
| 446 | + InputEvent::Expose => { | |
| 447 | + ev.request_redraw(); | |
| 448 | + } | |
| 449 | + InputEvent::CloseRequested => { | |
| 450 | + self.should_quit = true; | |
| 451 | + } | |
| 452 | + InputEvent::FocusOut => { | |
| 453 | + // Don't close on focus out - this is a config app, not a popup | |
| 454 | + } | |
| 455 | + InputEvent::Resize { width, height } => { | |
| 456 | + self.layout = Layout::new(width, height); | |
| 457 | + if let Ok(new_renderer) = | |
| 458 | + Renderer::with_theme(width, height, self.theme.clone()) | |
| 459 | + { | |
| 460 | + self.renderer = new_renderer; | |
| 461 | + } | |
| 462 | + self.blit_buffer = None; // Recreate on next render | |
| 463 | + ev.request_redraw(); | |
| 464 | + } | |
| 465 | + _ => {} | |
| 466 | + } | |
| 467 | + | |
| 468 | + // Poll for IPC events (throttled) | |
| 469 | + if self.last_ipc_poll.elapsed().as_millis() >= IPC_POLL_INTERVAL_MS as u128 { | |
| 470 | + self.poll_ipc_events(); | |
| 471 | + self.last_ipc_poll = Instant::now(); | |
| 472 | + } | |
| 473 | + | |
| 474 | + // Periodically refresh connections | |
| 475 | + if self.last_refresh.elapsed().as_secs() >= REFRESH_INTERVAL_SECS { | |
| 476 | + self.refresh_connections(); | |
| 477 | + ev.request_redraw(); | |
| 478 | + } | |
| 479 | + | |
| 480 | + if ev.needs_redraw() { | |
| 481 | + let _ = self.render(); | |
| 482 | + ev.redraw_done(); | |
| 483 | + } | |
| 484 | + | |
| 485 | + Ok(!self.should_quit) | |
| 486 | + })?; | |
| 487 | + | |
| 488 | + Ok(()) | |
| 489 | + } | |
| 490 | + | |
| 491 | + /// Run the application in daemon mode (handles IPC commands) | |
| 492 | + pub fn run_daemon(&mut self, command_rx: Receiver<DaemonCommand>) -> Result<()> { | |
| 493 | + let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?; | |
| 494 | + | |
| 495 | + tracing::info!("Daemon mode started (window hidden, use gargearsctl to show)"); | |
| 496 | + | |
| 497 | + event_loop.run(|ev, event| { | |
| 498 | + // Check for daemon commands first | |
| 499 | + while let Some(cmd) = check_command(&command_rx) { | |
| 500 | + match cmd { | |
| 501 | + DaemonCommand::Show => { | |
| 502 | + let _ = self.show(); | |
| 503 | + ev.request_redraw(); | |
| 504 | + } | |
| 505 | + DaemonCommand::Hide => { | |
| 506 | + let _ = self.hide(); | |
| 507 | + } | |
| 508 | + DaemonCommand::Toggle => { | |
| 509 | + let _ = self.toggle(); | |
| 510 | + if self.visible.load(Ordering::Relaxed) { | |
| 511 | + ev.request_redraw(); | |
| 512 | + } | |
| 513 | + } | |
| 514 | + DaemonCommand::Status => { | |
| 515 | + // Handled by IPC server directly | |
| 516 | + } | |
| 517 | + DaemonCommand::Quit => { | |
| 518 | + self.should_quit = true; | |
| 519 | + } | |
| 520 | + } | |
| 521 | + } | |
| 522 | + | |
| 523 | + // Only process events when visible | |
| 524 | + if self.visible.load(Ordering::Relaxed) { | |
| 525 | + match event.clone() { | |
| 526 | + InputEvent::Key(key_event) if key_event.pressed => { | |
| 527 | + // Escape hides in daemon mode instead of quitting | |
| 528 | + if matches!(key_event.key, Key::Escape) { | |
| 529 | + let _ = self.hide(); | |
| 530 | + } else if self.handle_key(&key_event.key) { | |
| 531 | + ev.request_redraw(); | |
| 532 | + } else if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 533 | + match panel.handle_event(&event) { | |
| 534 | + PanelAction::Apply => { | |
| 535 | + if let Err(e) = panel.apply() { | |
| 536 | + self.set_status(format!("Apply failed: {}", e)); | |
| 537 | + } else { | |
| 538 | + self.set_status("Changes applied"); | |
| 539 | + } | |
| 540 | + } | |
| 541 | + PanelAction::InstantApply => { | |
| 542 | + if let Err(e) = panel.apply_instant() { | |
| 543 | + self.set_status(format!("Apply failed: {}", e)); | |
| 544 | + } | |
| 545 | + } | |
| 546 | + PanelAction::Reset => { | |
| 547 | + panel.reset(); | |
| 548 | + self.set_status("Reset to original values"); | |
| 549 | + } | |
| 550 | + PanelAction::Save => { | |
| 551 | + if panel.has_config_file() { | |
| 552 | + if let Err(e) = panel.save_to_config() { | |
| 553 | + self.set_status(format!("Save failed: {}", e)); | |
| 554 | + } else { | |
| 555 | + self.set_status("Saved to config file"); | |
| 556 | + } | |
| 557 | + } | |
| 558 | + } | |
| 559 | + PanelAction::Redraw | PanelAction::None => {} | |
| 560 | + } | |
| 561 | + ev.request_redraw(); | |
| 562 | + } | |
| 563 | + } | |
| 564 | + InputEvent::MousePress(mouse_event) => { | |
| 565 | + let x = mouse_event.position.x; | |
| 566 | + let y = mouse_event.position.y; | |
| 567 | + | |
| 568 | + let sidebar_bounds = self.layout.sidebar_bounds(); | |
| 569 | + if x >= sidebar_bounds.x | |
| 570 | + && x < sidebar_bounds.x + sidebar_bounds.width as i32 | |
| 571 | + && y >= sidebar_bounds.y | |
| 572 | + && y < sidebar_bounds.y + sidebar_bounds.height as i32 | |
| 573 | + { | |
| 574 | + if let Some(component) = | |
| 575 | + self.sidebar.component_at_y(y, &self.layout, &self.theme) | |
| 576 | + { | |
| 577 | + self.selected_component = component; | |
| 578 | + } | |
| 579 | + } else if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 580 | + match panel.handle_event(&event) { | |
| 581 | + PanelAction::Apply => { | |
| 582 | + if let Err(e) = panel.apply() { | |
| 583 | + self.set_status(format!("Apply failed: {}", e)); | |
| 584 | + } else { | |
| 585 | + self.set_status("Changes applied"); | |
| 586 | + } | |
| 587 | + } | |
| 588 | + PanelAction::InstantApply => { | |
| 589 | + if let Err(e) = panel.apply_instant() { | |
| 590 | + self.set_status(format!("Apply failed: {}", e)); | |
| 591 | + } | |
| 592 | + } | |
| 593 | + PanelAction::Reset => { | |
| 594 | + panel.reset(); | |
| 595 | + self.set_status("Reset to original values"); | |
| 596 | + } | |
| 597 | + PanelAction::Save => { | |
| 598 | + if panel.has_config_file() { | |
| 599 | + if let Err(e) = panel.save_to_config() { | |
| 600 | + self.set_status(format!("Save failed: {}", e)); | |
| 601 | + } else { | |
| 602 | + self.set_status("Saved to config file"); | |
| 603 | + } | |
| 604 | + } | |
| 605 | + } | |
| 606 | + PanelAction::Redraw | PanelAction::None => {} | |
| 607 | + } | |
| 608 | + } | |
| 609 | + ev.request_redraw(); | |
| 610 | + } | |
| 611 | + InputEvent::MouseMove(mouse_event) => { | |
| 612 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 613 | + if panel.on_mouse_move(mouse_event.position.x, mouse_event.position.y) { | |
| 614 | + ev.request_redraw(); | |
| 615 | + } | |
| 616 | + } | |
| 617 | + } | |
| 618 | + InputEvent::Scroll(_) => { | |
| 619 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 620 | + panel.handle_event(&event); | |
| 621 | + } | |
| 622 | + ev.request_redraw(); | |
| 623 | + } | |
| 624 | + InputEvent::Expose => { | |
| 625 | + ev.request_redraw(); | |
| 626 | + } | |
| 627 | + InputEvent::CloseRequested => { | |
| 628 | + // In daemon mode, hide instead of quit | |
| 629 | + let _ = self.hide(); | |
| 630 | + } | |
| 631 | + InputEvent::Resize { width, height } => { | |
| 632 | + self.layout = Layout::new(width, height); | |
| 633 | + if let Ok(new_renderer) = | |
| 634 | + Renderer::with_theme(width, height, self.theme.clone()) | |
| 635 | + { | |
| 636 | + self.renderer = new_renderer; | |
| 637 | + } | |
| 638 | + self.blit_buffer = None; // Recreate on next render | |
| 639 | + ev.request_redraw(); | |
| 640 | + } | |
| 641 | + _ => {} | |
| 642 | + } | |
| 643 | + | |
| 644 | + if ev.needs_redraw() { | |
| 645 | + let _ = self.render(); | |
| 646 | + ev.redraw_done(); | |
| 647 | + } | |
| 648 | + } | |
| 649 | + | |
| 650 | + // Poll IPC events (throttled) and refresh connections | |
| 651 | + if self.last_ipc_poll.elapsed().as_millis() >= IPC_POLL_INTERVAL_MS as u128 { | |
| 652 | + self.poll_ipc_events(); | |
| 653 | + self.last_ipc_poll = Instant::now(); | |
| 654 | + } | |
| 655 | + if self.last_refresh.elapsed().as_secs() >= REFRESH_INTERVAL_SECS { | |
| 656 | + self.refresh_connections(); | |
| 657 | + } | |
| 658 | + | |
| 659 | + Ok(!self.should_quit) | |
| 660 | + })?; | |
| 661 | + | |
| 662 | + Ok(()) | |
| 663 | + } | |
| 664 | + | |
| 665 | + /// Poll for IPC events from connected daemons | |
| 666 | + fn poll_ipc_events(&mut self) { | |
| 667 | + let events = self.connection_manager.poll_events(); | |
| 668 | + | |
| 669 | + for event in events { | |
| 670 | + match event { | |
| 671 | + IpcEvent::Connected(component) => { | |
| 672 | + tracing::info!("Connected to {:?}", component); | |
| 673 | + self.set_status(format!("Connected to {}", component.display_name())); | |
| 674 | + self.daemon_status = self.connection_manager.get_statuses(); | |
| 675 | + } | |
| 676 | + IpcEvent::Disconnected(component) => { | |
| 677 | + tracing::info!("Disconnected from {:?}", component); | |
| 678 | + self.set_status(format!("Disconnected from {}", component.display_name())); | |
| 679 | + self.daemon_status = self.connection_manager.get_statuses(); | |
| 680 | + } | |
| 681 | + IpcEvent::WorkspaceChanged { new, .. } => { | |
| 682 | + tracing::debug!("Workspace changed to {}", new); | |
| 683 | + } | |
| 684 | + IpcEvent::WallpaperChanged { monitor, source } => { | |
| 685 | + tracing::debug!("Wallpaper changed on {}: {}", monitor, source); | |
| 686 | + } | |
| 687 | + IpcEvent::StatusUpdate { component, .. } => { | |
| 688 | + tracing::debug!("Status update from {:?}", component); | |
| 689 | + } | |
| 690 | + IpcEvent::Error { component, message } => { | |
| 691 | + tracing::warn!("Error from {:?}: {}", component, message); | |
| 692 | + self.set_status(format!("{}: {}", component.display_name(), message)); | |
| 693 | + } | |
| 694 | + } | |
| 695 | + } | |
| 696 | + } | |
| 697 | + | |
| 698 | + /// Refresh connections to daemons | |
| 699 | + fn refresh_connections(&mut self) { | |
| 700 | + self.connection_manager.refresh_connections(); | |
| 701 | + self.daemon_status = self.connection_manager.get_statuses(); | |
| 702 | + self.last_refresh = Instant::now(); | |
| 703 | + } | |
| 704 | + | |
| 705 | + /// Handle a key press. Returns true if handled as a global key. | |
| 706 | + fn handle_key(&mut self, key: &Key) -> bool { | |
| 707 | + match key { | |
| 708 | + Key::Escape => { | |
| 709 | + self.should_quit = true; | |
| 710 | + true | |
| 711 | + } | |
| 712 | + Key::Char('q') => { | |
| 713 | + self.should_quit = true; | |
| 714 | + true | |
| 715 | + } | |
| 716 | + Key::Char('r') => { | |
| 717 | + // Manual refresh | |
| 718 | + self.refresh_connections(); | |
| 719 | + self.set_status("Refreshed connections"); | |
| 720 | + true | |
| 721 | + } | |
| 722 | + Key::Char('i') => { | |
| 723 | + // Toggle instant-apply mode | |
| 724 | + self.instant_apply = !self.instant_apply; | |
| 725 | + // Update all panels | |
| 726 | + for panel in self.panels.values_mut() { | |
| 727 | + panel.set_instant_apply(self.instant_apply); | |
| 728 | + } | |
| 729 | + self.set_status(if self.instant_apply { | |
| 730 | + "Instant apply enabled" | |
| 731 | + } else { | |
| 732 | + "Instant apply disabled" | |
| 733 | + }); | |
| 734 | + true | |
| 735 | + } | |
| 736 | + Key::Tab => { | |
| 737 | + self.select_next_component(); | |
| 738 | + true | |
| 739 | + } | |
| 740 | + _ => false, | |
| 741 | + } | |
| 742 | + } | |
| 743 | + | |
| 744 | + /// Set a status message that will auto-expire | |
| 745 | + fn set_status(&mut self, message: impl Into<String>) { | |
| 746 | + self.status_message = Some((message.into(), Instant::now())); | |
| 747 | + } | |
| 748 | + /// Clear expired status messages | |
| 749 | + fn clear_expired_status(&mut self) { | |
| 750 | + if let Some((_, timestamp)) = &self.status_message { | |
| 751 | + if timestamp.elapsed() > STATUS_MESSAGE_DURATION { | |
| 752 | + self.status_message = None; | |
| 753 | + } | |
| 754 | + } | |
| 755 | + } | |
| 756 | + | |
| 757 | + /// Select the previous component | |
| 758 | + fn select_prev_component(&mut self) { | |
| 759 | + let components = Component::all(); | |
| 760 | + if let Some(idx) = components.iter().position(|c| *c == self.selected_component) { | |
| 761 | + if idx > 0 { | |
| 762 | + self.selected_component = components[idx - 1]; | |
| 763 | + } | |
| 764 | + } | |
| 765 | + } | |
| 766 | + | |
| 767 | + /// Select the next component | |
| 768 | + fn select_next_component(&mut self) { | |
| 769 | + let components = Component::all(); | |
| 770 | + if let Some(idx) = components.iter().position(|c| *c == self.selected_component) { | |
| 771 | + if idx + 1 < components.len() { | |
| 772 | + self.selected_component = components[idx + 1]; | |
| 773 | + } | |
| 774 | + } | |
| 775 | + } | |
| 776 | + | |
| 777 | + /// Render the application | |
| 778 | + fn render(&mut self) -> Result<()> { | |
| 779 | + // Clear background | |
| 780 | + self.renderer.clear()?; | |
| 781 | + | |
| 782 | + // Draw sidebar | |
| 783 | + self.sidebar.render( | |
| 784 | + &mut self.renderer, | |
| 785 | + &self.layout, | |
| 786 | + &self.theme, | |
| 787 | + self.selected_component, | |
| 788 | + &self.daemon_status, | |
| 789 | + )?; | |
| 790 | + | |
| 791 | + // Draw content panel | |
| 792 | + self.render_content_panel()?; | |
| 793 | + | |
| 794 | + // Render status message if present | |
| 795 | + self.render_status_message()?; | |
| 796 | + | |
| 797 | + // Flush and blit | |
| 798 | + self.renderer.flush(); | |
| 799 | + self.blit_surface()?; | |
| 800 | + | |
| 801 | + Ok(()) | |
| 802 | + } | |
| 803 | + | |
| 804 | + /// Render the content panel | |
| 805 | + fn render_content_panel(&mut self) -> Result<()> { | |
| 806 | + let bounds = self.layout.content_bounds(); | |
| 807 | + | |
| 808 | + // Draw panel background | |
| 809 | + self.renderer.fill_rounded_rect( | |
| 810 | + bounds, | |
| 811 | + self.theme.border_radius, | |
| 812 | + self.theme.item_background, | |
| 813 | + )?; | |
| 814 | + | |
| 815 | + // Render the current panel | |
| 816 | + if let Some(panel) = self.panels.get_mut(&self.selected_component) { | |
| 817 | + // Panel content area (inset slightly from background) | |
| 818 | + let content_bounds = Rect::new( | |
| 819 | + bounds.x, | |
| 820 | + bounds.y, | |
| 821 | + bounds.width, | |
| 822 | + bounds.height, | |
| 823 | + ); | |
| 824 | + | |
| 825 | + panel.render(&mut self.renderer, content_bounds, &self.theme)?; | |
| 826 | + } | |
| 827 | + | |
| 828 | + Ok(()) | |
| 829 | + } | |
| 830 | + | |
| 831 | + /// Render status message at the bottom of the window | |
| 832 | + fn render_status_message(&mut self) -> Result<()> { | |
| 833 | + // Clear expired messages | |
| 834 | + self.clear_expired_status(); | |
| 835 | + | |
| 836 | + if let Some((message, timestamp)) = &self.status_message { | |
| 837 | + let size = self.renderer.size(); | |
| 838 | + let elapsed = timestamp.elapsed(); | |
| 839 | + | |
| 840 | + // Calculate fade opacity (fade out during last second) | |
| 841 | + let opacity = if elapsed > STATUS_MESSAGE_DURATION - Duration::from_secs(1) { | |
| 842 | + let fade_elapsed = (elapsed - (STATUS_MESSAGE_DURATION - Duration::from_secs(1))).as_secs_f64(); | |
| 843 | + (1.0 - fade_elapsed).max(0.0) | |
| 844 | + } else { | |
| 845 | + 1.0 | |
| 846 | + }; | |
| 847 | + | |
| 848 | + // Determine color based on message content | |
| 849 | + let is_error = message.contains("failed") || message.contains("Error"); | |
| 850 | + let base_color = if is_error { | |
| 851 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) // Red for errors | |
| 852 | + } else { | |
| 853 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) // Green for success | |
| 854 | + }; | |
| 855 | + | |
| 856 | + let color = base_color.with_alpha(opacity); | |
| 857 | + | |
| 858 | + let style = gartk_render::TextStyle::new() | |
| 859 | + .font_family(&self.theme.font_family) | |
| 860 | + .font_size(self.theme.font_size * 0.85) | |
| 861 | + .color(color); | |
| 862 | + | |
| 863 | + // Render at bottom center | |
| 864 | + let center = Point { | |
| 865 | + x: (size.width / 2) as i32, | |
| 866 | + y: (size.height - 24) as i32, | |
| 867 | + }; | |
| 868 | + | |
| 869 | + self.renderer.text_centered(message, center, &style)?; | |
| 870 | + } | |
| 871 | + | |
| 872 | + Ok(()) | |
| 873 | + } | |
| 874 | + | |
| 875 | + /// Blit the rendered surface to the window | |
| 876 | + fn blit_surface(&mut self) -> Result<()> { | |
| 877 | + let size = self.renderer.size(); | |
| 878 | + | |
| 879 | + // Ensure blit buffer exists and is the right size | |
| 880 | + let needs_new_buffer = match &self.blit_buffer { | |
| 881 | + Some(buf) => buf.width() != size.width || buf.height() != size.height, | |
| 882 | + None => true, | |
| 883 | + }; | |
| 884 | + | |
| 885 | + if needs_new_buffer { | |
| 886 | + self.blit_buffer = Some(gartk_render::Surface::new(size.width, size.height)?); | |
| 887 | + } | |
| 888 | + | |
| 889 | + // Copy renderer surface to blit buffer | |
| 890 | + { | |
| 891 | + let ctx = self.renderer.context()?; | |
| 892 | + ctx.target().flush(); | |
| 893 | + } | |
| 894 | + | |
| 895 | + let blit_buffer = self.blit_buffer.as_mut().unwrap(); | |
| 896 | + { | |
| 897 | + let blit_ctx = blit_buffer.context()?; | |
| 898 | + blit_ctx.set_source_surface(self.renderer.surface().cairo_surface(), 0.0, 0.0)?; | |
| 899 | + blit_ctx.paint()?; | |
| 900 | + } | |
| 901 | + | |
| 902 | + // Blit to X11 using with_data to avoid Vec allocation | |
| 903 | + let conn = self.window.connection(); | |
| 904 | + let window_id = self.window.id(); | |
| 905 | + let gc = self.gc; | |
| 906 | + let depth = self.window.depth(); | |
| 907 | + | |
| 908 | + blit_buffer.with_data(|data| { | |
| 909 | + let _ = conn.inner().put_image( | |
| 910 | + ImageFormat::Z_PIXMAP, | |
| 911 | + window_id, | |
| 912 | + gc, | |
| 913 | + size.width as u16, | |
| 914 | + size.height as u16, | |
| 915 | + 0, | |
| 916 | + 0, | |
| 917 | + 0, | |
| 918 | + depth, | |
| 919 | + data, | |
| 920 | + ); | |
| 921 | + })?; | |
| 922 | + | |
| 923 | + conn.flush()?; | |
| 924 | + | |
| 925 | + Ok(()) | |
| 926 | + } | |
| 927 | +} | |
| 928 | + | |
| 929 | +impl Drop for App { | |
| 930 | + fn drop(&mut self) { | |
| 931 | + let _ = self.window.connection().inner().free_gc(self.gc); | |
| 932 | + } | |
| 933 | +} | |
gargears/src/config/lua_writer.rsadded@@ -0,0 +1,364 @@ | ||
| 1 | +//! Lua configuration writer for gardesk components | |
| 2 | +//! | |
| 3 | +//! Handles writing configuration for: gar, garbar, garterm | |
| 4 | +//! These share ~/.config/gar/init.lua | |
| 5 | + | |
| 6 | +use anyhow::{Context, Result}; | |
| 7 | +use std::collections::HashMap; | |
| 8 | +use std::fs; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Get the path to the shared Lua config | |
| 12 | +pub fn lua_config_path() -> PathBuf { | |
| 13 | + let config_dir = std::env::var("XDG_CONFIG_HOME") | |
| 14 | + .map(PathBuf::from) | |
| 15 | + .unwrap_or_else(|_| { | |
| 16 | + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); | |
| 17 | + PathBuf::from(home).join(".config") | |
| 18 | + }); | |
| 19 | + config_dir.join("gar").join("init.lua") | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// Represents a Lua value that can be serialized | |
| 23 | +#[derive(Debug, Clone)] | |
| 24 | +pub enum LuaValue { | |
| 25 | + Number(i64), | |
| 26 | + Float(f64), | |
| 27 | + String(String), | |
| 28 | + Bool(bool), | |
| 29 | + Color(String), | |
| 30 | +} | |
| 31 | + | |
| 32 | +impl LuaValue { | |
| 33 | + pub fn to_lua(&self) -> String { | |
| 34 | + match self { | |
| 35 | + LuaValue::Number(n) => n.to_string(), | |
| 36 | + LuaValue::Float(f) => format!("{:.2}", f), | |
| 37 | + LuaValue::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), | |
| 38 | + LuaValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), | |
| 39 | + LuaValue::Color(c) => format!("\"{}\"", c), | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// gar window manager settings | |
| 45 | +#[derive(Debug, Clone, Default)] | |
| 46 | +pub struct GarSettings { | |
| 47 | + pub border_width: Option<u32>, | |
| 48 | + pub gap_inner: Option<u32>, | |
| 49 | + pub gap_outer: Option<u32>, | |
| 50 | + pub border_color_focused: Option<String>, | |
| 51 | + pub border_color_unfocused: Option<String>, | |
| 52 | + pub border_color_urgent: Option<String>, | |
| 53 | + pub focus_follows_mouse: Option<bool>, | |
| 54 | +} | |
| 55 | + | |
| 56 | +impl GarSettings { | |
| 57 | + pub fn to_lua_statements(&self) -> Vec<(String, LuaValue)> { | |
| 58 | + let mut statements = Vec::new(); | |
| 59 | + | |
| 60 | + if let Some(v) = self.border_width { | |
| 61 | + statements.push(("border_width".to_string(), LuaValue::Number(v as i64))); | |
| 62 | + } | |
| 63 | + if let Some(v) = self.gap_inner { | |
| 64 | + statements.push(("gap_inner".to_string(), LuaValue::Number(v as i64))); | |
| 65 | + } | |
| 66 | + if let Some(v) = self.gap_outer { | |
| 67 | + statements.push(("gap_outer".to_string(), LuaValue::Number(v as i64))); | |
| 68 | + } | |
| 69 | + if let Some(ref v) = self.border_color_focused { | |
| 70 | + statements.push(("border_color_focused".to_string(), LuaValue::Color(v.clone()))); | |
| 71 | + } | |
| 72 | + if let Some(ref v) = self.border_color_unfocused { | |
| 73 | + statements.push(("border_color_unfocused".to_string(), LuaValue::Color(v.clone()))); | |
| 74 | + } | |
| 75 | + if let Some(ref v) = self.border_color_urgent { | |
| 76 | + statements.push(("border_color_urgent".to_string(), LuaValue::Color(v.clone()))); | |
| 77 | + } | |
| 78 | + if let Some(v) = self.focus_follows_mouse { | |
| 79 | + statements.push(("focus_follows_mouse".to_string(), LuaValue::Bool(v))); | |
| 80 | + } | |
| 81 | + | |
| 82 | + statements | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +/// garbar status bar settings | |
| 87 | +#[derive(Debug, Clone, Default)] | |
| 88 | +pub struct GarbarSettings { | |
| 89 | + pub height: Option<u32>, | |
| 90 | + pub position: Option<String>, | |
| 91 | + pub font_family: Option<String>, | |
| 92 | + pub font_size: Option<f64>, | |
| 93 | + pub background_color: Option<String>, | |
| 94 | + pub foreground_color: Option<String>, | |
| 95 | + pub modules_left: Option<Vec<String>>, | |
| 96 | + pub modules_center: Option<Vec<String>>, | |
| 97 | + pub modules_right: Option<Vec<String>>, | |
| 98 | +} | |
| 99 | + | |
| 100 | +impl GarbarSettings { | |
| 101 | + pub fn to_lua_table(&self) -> String { | |
| 102 | + let mut lines = Vec::new(); | |
| 103 | + lines.push("gar.bar = {".to_string()); | |
| 104 | + | |
| 105 | + if let Some(v) = self.height { | |
| 106 | + lines.push(format!(" height = {},", v)); | |
| 107 | + } | |
| 108 | + if let Some(ref v) = self.position { | |
| 109 | + lines.push(format!(" position = \"{}\",", v)); | |
| 110 | + } | |
| 111 | + if let Some(ref v) = self.font_family { | |
| 112 | + lines.push(format!(" font_family = \"{}\",", v)); | |
| 113 | + } | |
| 114 | + if let Some(v) = self.font_size { | |
| 115 | + lines.push(format!(" font_size = {:.1},", v)); | |
| 116 | + } | |
| 117 | + if let Some(ref v) = self.background_color { | |
| 118 | + lines.push(format!(" background = \"{}\",", v)); | |
| 119 | + } | |
| 120 | + if let Some(ref v) = self.foreground_color { | |
| 121 | + lines.push(format!(" foreground = \"{}\",", v)); | |
| 122 | + } | |
| 123 | + if let Some(ref v) = self.modules_left { | |
| 124 | + let mods: Vec<String> = v.iter().map(|m| format!("\"{}\"", m)).collect(); | |
| 125 | + lines.push(format!(" modules_left = {{ {} }},", mods.join(", "))); | |
| 126 | + } | |
| 127 | + if let Some(ref v) = self.modules_center { | |
| 128 | + let mods: Vec<String> = v.iter().map(|m| format!("\"{}\"", m)).collect(); | |
| 129 | + lines.push(format!(" modules_center = {{ {} }},", mods.join(", "))); | |
| 130 | + } | |
| 131 | + if let Some(ref v) = self.modules_right { | |
| 132 | + let mods: Vec<String> = v.iter().map(|m| format!("\"{}\"", m)).collect(); | |
| 133 | + lines.push(format!(" modules_right = {{ {} }},", mods.join(", "))); | |
| 134 | + } | |
| 135 | + | |
| 136 | + lines.push("}".to_string()); | |
| 137 | + lines.join("\n") | |
| 138 | + } | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// garterm terminal settings | |
| 142 | +#[derive(Debug, Clone, Default)] | |
| 143 | +pub struct GartermSettings { | |
| 144 | + pub font_family: Option<String>, | |
| 145 | + pub font_size: Option<f64>, | |
| 146 | + pub scrollback_lines: Option<u32>, | |
| 147 | + pub cursor_style: Option<String>, | |
| 148 | + pub cursor_blink: Option<bool>, | |
| 149 | +} | |
| 150 | + | |
| 151 | +impl GartermSettings { | |
| 152 | + pub fn to_lua_table(&self) -> String { | |
| 153 | + let mut lines = Vec::new(); | |
| 154 | + lines.push("gar.terminal = {".to_string()); | |
| 155 | + | |
| 156 | + if let Some(ref v) = self.font_family { | |
| 157 | + lines.push(format!(" font_family = \"{}\",", v)); | |
| 158 | + } | |
| 159 | + if let Some(v) = self.font_size { | |
| 160 | + lines.push(format!(" font_size = {:.1},", v)); | |
| 161 | + } | |
| 162 | + if let Some(v) = self.scrollback_lines { | |
| 163 | + lines.push(format!(" scrollback = {},", v)); | |
| 164 | + } | |
| 165 | + if let Some(ref v) = self.cursor_style { | |
| 166 | + lines.push(format!(" cursor_style = \"{}\",", v)); | |
| 167 | + } | |
| 168 | + if let Some(v) = self.cursor_blink { | |
| 169 | + lines.push(format!(" cursor_blink = {},", v)); | |
| 170 | + } | |
| 171 | + | |
| 172 | + lines.push("}".to_string()); | |
| 173 | + lines.join("\n") | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +/// Lua config writer that preserves existing structure | |
| 178 | +pub struct LuaConfigWriter { | |
| 179 | + content: String, | |
| 180 | + path: PathBuf, | |
| 181 | +} | |
| 182 | + | |
| 183 | +impl LuaConfigWriter { | |
| 184 | + /// Load existing config or create empty | |
| 185 | + pub fn load() -> Result<Self> { | |
| 186 | + let path = lua_config_path(); | |
| 187 | + let content = if path.exists() { | |
| 188 | + fs::read_to_string(&path) | |
| 189 | + .with_context(|| format!("Failed to read {}", path.display()))? | |
| 190 | + } else { | |
| 191 | + String::new() | |
| 192 | + }; | |
| 193 | + Ok(Self { content, path }) | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Update a gar.set() call, or add it if not present | |
| 197 | + pub fn set_value(&mut self, key: &str, value: LuaValue) { | |
| 198 | + let pattern = format!(r#"gar\.set\(\s*["']{}["']\s*,\s*[^)]+\)"#, regex::escape(key)); | |
| 199 | + let replacement = format!("gar.set(\"{}\", {})", key, value.to_lua()); | |
| 200 | + | |
| 201 | + if let Ok(re) = regex::Regex::new(&pattern) { | |
| 202 | + if re.is_match(&self.content) { | |
| 203 | + self.content = re.replace(&self.content, replacement.as_str()).to_string(); | |
| 204 | + } else { | |
| 205 | + // Add new setting after last gar.set() or at start | |
| 206 | + if let Some(pos) = self.find_last_gar_set() { | |
| 207 | + // Find end of line | |
| 208 | + let end = self.content[pos..].find('\n').map(|p| pos + p + 1).unwrap_or(self.content.len()); | |
| 209 | + self.content.insert_str(end, &format!("{}\n", replacement)); | |
| 210 | + } else { | |
| 211 | + // No existing gar.set(), add at beginning | |
| 212 | + self.content = format!("{}\n{}", replacement, self.content); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + /// Update multiple values | |
| 219 | + pub fn set_values(&mut self, settings: &[(String, LuaValue)]) { | |
| 220 | + for (key, value) in settings { | |
| 221 | + self.set_value(key, value.clone()); | |
| 222 | + } | |
| 223 | + } | |
| 224 | + | |
| 225 | + /// Update gar.bar table | |
| 226 | + pub fn set_bar_table(&mut self, settings: &GarbarSettings) { | |
| 227 | + let table = settings.to_lua_table(); | |
| 228 | + | |
| 229 | + // Try to find and replace existing gar.bar block | |
| 230 | + if let Ok(re) = regex::Regex::new(r"gar\.bar\s*=\s*\{[^}]*\}") { | |
| 231 | + if re.is_match(&self.content) { | |
| 232 | + self.content = re.replace(&self.content, table.as_str()).to_string(); | |
| 233 | + return; | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + // No existing block, add after gar.set() calls or at end | |
| 238 | + self.content.push_str("\n\n"); | |
| 239 | + self.content.push_str(&table); | |
| 240 | + self.content.push('\n'); | |
| 241 | + } | |
| 242 | + | |
| 243 | + /// Update gar.terminal table | |
| 244 | + pub fn set_terminal_table(&mut self, settings: &GartermSettings) { | |
| 245 | + let table = settings.to_lua_table(); | |
| 246 | + | |
| 247 | + // Try to find and replace existing gar.terminal block | |
| 248 | + if let Ok(re) = regex::Regex::new(r"gar\.terminal\s*=\s*\{[^}]*\}") { | |
| 249 | + if re.is_match(&self.content) { | |
| 250 | + self.content = re.replace(&self.content, table.as_str()).to_string(); | |
| 251 | + return; | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + // No existing block, add after gar.bar or at end | |
| 256 | + self.content.push_str("\n\n"); | |
| 257 | + self.content.push_str(&table); | |
| 258 | + self.content.push('\n'); | |
| 259 | + } | |
| 260 | + | |
| 261 | + /// Save the config file | |
| 262 | + pub fn save(&self) -> Result<()> { | |
| 263 | + if let Some(parent) = self.path.parent() { | |
| 264 | + fs::create_dir_all(parent) | |
| 265 | + .with_context(|| format!("Failed to create {}", parent.display()))?; | |
| 266 | + } | |
| 267 | + | |
| 268 | + // Create backup | |
| 269 | + if self.path.exists() { | |
| 270 | + let backup = self.path.with_extension("lua.bak"); | |
| 271 | + let _ = fs::copy(&self.path, &backup); | |
| 272 | + } | |
| 273 | + | |
| 274 | + fs::write(&self.path, &self.content) | |
| 275 | + .with_context(|| format!("Failed to write {}", self.path.display())) | |
| 276 | + } | |
| 277 | + | |
| 278 | + /// Find position of last gar.set() call | |
| 279 | + fn find_last_gar_set(&self) -> Option<usize> { | |
| 280 | + let pattern = r"gar\.set\("; | |
| 281 | + let mut last_pos = None; | |
| 282 | + | |
| 283 | + if let Ok(re) = regex::Regex::new(pattern) { | |
| 284 | + for m in re.find_iter(&self.content) { | |
| 285 | + last_pos = Some(m.start()); | |
| 286 | + } | |
| 287 | + } | |
| 288 | + | |
| 289 | + last_pos | |
| 290 | + } | |
| 291 | + | |
| 292 | + /// Get current content (for preview) | |
| 293 | + pub fn content(&self) -> &str { | |
| 294 | + &self.content | |
| 295 | + } | |
| 296 | +} | |
| 297 | + | |
| 298 | +/// Helper to extract current gar.set() values from config | |
| 299 | +pub fn parse_gar_sets(content: &str) -> HashMap<String, String> { | |
| 300 | + let mut values = HashMap::new(); | |
| 301 | + | |
| 302 | + // Match gar.set("key", value) patterns | |
| 303 | + if let Ok(re) = regex::Regex::new(r#"gar\.set\(\s*["']([^"']+)["']\s*,\s*([^)]+)\)"#) { | |
| 304 | + for cap in re.captures_iter(content) { | |
| 305 | + if let (Some(key), Some(val)) = (cap.get(1), cap.get(2)) { | |
| 306 | + values.insert(key.as_str().to_string(), val.as_str().trim().to_string()); | |
| 307 | + } | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + values | |
| 312 | +} | |
| 313 | + | |
| 314 | +#[cfg(test)] | |
| 315 | +mod tests { | |
| 316 | + use super::*; | |
| 317 | + | |
| 318 | + #[test] | |
| 319 | + fn test_lua_value_serialization() { | |
| 320 | + assert_eq!(LuaValue::Number(42).to_lua(), "42"); | |
| 321 | + assert_eq!(LuaValue::Float(3.14).to_lua(), "3.14"); | |
| 322 | + assert_eq!(LuaValue::String("hello".into()).to_lua(), "\"hello\""); | |
| 323 | + assert_eq!(LuaValue::Bool(true).to_lua(), "true"); | |
| 324 | + assert_eq!(LuaValue::Color("#ff0000".into()).to_lua(), "\"#ff0000\""); | |
| 325 | + } | |
| 326 | + | |
| 327 | + #[test] | |
| 328 | + fn test_gar_settings() { | |
| 329 | + let settings = GarSettings { | |
| 330 | + border_width: Some(2), | |
| 331 | + gap_inner: Some(10), | |
| 332 | + ..Default::default() | |
| 333 | + }; | |
| 334 | + let stmts = settings.to_lua_statements(); | |
| 335 | + assert_eq!(stmts.len(), 2); | |
| 336 | + } | |
| 337 | + | |
| 338 | + #[test] | |
| 339 | + fn test_garbar_table() { | |
| 340 | + let settings = GarbarSettings { | |
| 341 | + height: Some(28), | |
| 342 | + position: Some("top".into()), | |
| 343 | + modules_left: Some(vec!["workspaces".into()]), | |
| 344 | + ..Default::default() | |
| 345 | + }; | |
| 346 | + let table = settings.to_lua_table(); | |
| 347 | + assert!(table.contains("height = 28")); | |
| 348 | + assert!(table.contains("position = \"top\"")); | |
| 349 | + assert!(table.contains("modules_left = { \"workspaces\" }")); | |
| 350 | + } | |
| 351 | + | |
| 352 | + #[test] | |
| 353 | + fn test_parse_gar_sets() { | |
| 354 | + let content = r#" | |
| 355 | +gar.set("border_width", 2) | |
| 356 | +gar.set("gap_inner", 10) | |
| 357 | +gar.set('focus_follows_mouse', true) | |
| 358 | +"#; | |
| 359 | + let values = parse_gar_sets(content); | |
| 360 | + assert_eq!(values.get("border_width"), Some(&"2".to_string())); | |
| 361 | + assert_eq!(values.get("gap_inner"), Some(&"10".to_string())); | |
| 362 | + assert_eq!(values.get("focus_follows_mouse"), Some(&"true".to_string())); | |
| 363 | + } | |
| 364 | +} | |
gargears/src/config/mod.rsadded@@ -0,0 +1,77 @@ | ||
| 1 | +//! Configuration file reading and writing | |
| 2 | +//! | |
| 3 | +//! This module handles reading and writing configuration files for gardesk components. | |
| 4 | +//! - Lua config: gar, garbar, garterm (~/.config/gar/init.lua) | |
| 5 | +//! - TOML config: garbg, garlock, garshot, garclip | |
| 6 | + | |
| 7 | +mod lua_writer; | |
| 8 | +mod toml_writer; | |
| 9 | + | |
| 10 | +pub use lua_writer::{ | |
| 11 | + parse_gar_sets, GarSettings, GarbarSettings, GartermSettings, LuaConfigWriter, LuaValue, | |
| 12 | +}; | |
| 13 | +pub use toml_writer::{ | |
| 14 | + GarbgConfig, GarbgSlideshow, GarbgSource, GarclipConfig, GarlockConfig, GarshotConfig, | |
| 15 | +}; | |
| 16 | + | |
| 17 | +use anyhow::Result; | |
| 18 | +use std::process::Command; | |
| 19 | + | |
| 20 | +/// Trigger config reload for a component | |
| 21 | +pub fn reload_component(component: &str) -> Result<()> { | |
| 22 | + match component { | |
| 23 | + "gar" => { | |
| 24 | + // Send SIGHUP to gar or use IPC reload command | |
| 25 | + let _ = Command::new("garctl").arg("reload").spawn(); | |
| 26 | + } | |
| 27 | + "garbar" => { | |
| 28 | + // garbar reloads on config change via inotify, or SIGHUP | |
| 29 | + let _ = Command::new("pkill") | |
| 30 | + .args(["-HUP", "garbar"]) | |
| 31 | + .spawn(); | |
| 32 | + } | |
| 33 | + "garbg" => { | |
| 34 | + let _ = Command::new("garbgctl").arg("reload").spawn(); | |
| 35 | + } | |
| 36 | + "garlock" => { | |
| 37 | + // garlock typically reloads on next lock | |
| 38 | + } | |
| 39 | + "garshot" => { | |
| 40 | + // garshot reads config on each invocation | |
| 41 | + } | |
| 42 | + "garclip" => { | |
| 43 | + let _ = Command::new("pkill") | |
| 44 | + .args(["-HUP", "garclip"]) | |
| 45 | + .spawn(); | |
| 46 | + } | |
| 47 | + "garterm" => { | |
| 48 | + // garterm reads config on new window | |
| 49 | + } | |
| 50 | + _ => {} | |
| 51 | + } | |
| 52 | + Ok(()) | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Check if a config file exists | |
| 56 | +pub fn config_exists(component: &str) -> bool { | |
| 57 | + match component { | |
| 58 | + "gar" | "garbar" | "garterm" => lua_writer::lua_config_path().exists(), | |
| 59 | + "garbg" => toml_writer::GarbgConfig::config_path().exists(), | |
| 60 | + "garlock" => toml_writer::GarlockConfig::config_path().exists(), | |
| 61 | + "garshot" => toml_writer::GarshotConfig::config_path().exists(), | |
| 62 | + "garclip" => toml_writer::GarclipConfig::config_path().exists(), | |
| 63 | + _ => false, | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Get the config path for a component | |
| 68 | +pub fn config_path(component: &str) -> Option<std::path::PathBuf> { | |
| 69 | + match component { | |
| 70 | + "gar" | "garbar" | "garterm" => Some(lua_writer::lua_config_path()), | |
| 71 | + "garbg" => Some(toml_writer::GarbgConfig::config_path()), | |
| 72 | + "garlock" => Some(toml_writer::GarlockConfig::config_path()), | |
| 73 | + "garshot" => Some(toml_writer::GarshotConfig::config_path()), | |
| 74 | + "garclip" => Some(toml_writer::GarclipConfig::config_path()), | |
| 75 | + _ => None, | |
| 76 | + } | |
| 77 | +} | |
gargears/src/config/toml_writer.rsadded@@ -0,0 +1,276 @@ | ||
| 1 | +//! TOML configuration writer for gardesk components | |
| 2 | +//! | |
| 3 | +//! Handles writing configuration for: garbg, garlock, garshot, garclip | |
| 4 | + | |
| 5 | +use anyhow::{Context, Result}; | |
| 6 | +use serde::{Deserialize, Serialize}; | |
| 7 | +use std::fs; | |
| 8 | +use std::path::PathBuf; | |
| 9 | + | |
| 10 | +/// Get XDG config directory | |
| 11 | +fn config_dir() -> PathBuf { | |
| 12 | + std::env::var("XDG_CONFIG_HOME") | |
| 13 | + .map(PathBuf::from) | |
| 14 | + .unwrap_or_else(|_| { | |
| 15 | + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); | |
| 16 | + PathBuf::from(home).join(".config") | |
| 17 | + }) | |
| 18 | +} | |
| 19 | + | |
| 20 | +/// garbg configuration | |
| 21 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 22 | +pub struct GarbgConfig { | |
| 23 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 24 | + pub sources: Option<Vec<GarbgSource>>, | |
| 25 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 26 | + pub slideshow: Option<GarbgSlideshow>, | |
| 27 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 28 | + pub fit: Option<String>, | |
| 29 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 30 | + pub color: Option<String>, | |
| 31 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 32 | + pub animation: Option<GarbgAnimation>, | |
| 33 | +} | |
| 34 | + | |
| 35 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 36 | +pub struct GarbgSource { | |
| 37 | + #[serde(rename = "type")] | |
| 38 | + pub source_type: String, | |
| 39 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 40 | + pub path: Option<String>, | |
| 41 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 42 | + pub url: Option<String>, | |
| 43 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 44 | + pub recursive: Option<bool>, | |
| 45 | +} | |
| 46 | + | |
| 47 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 48 | +pub struct GarbgSlideshow { | |
| 49 | + pub enabled: bool, | |
| 50 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 51 | + pub interval_secs: Option<u64>, | |
| 52 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 53 | + pub shuffle: Option<bool>, | |
| 54 | +} | |
| 55 | + | |
| 56 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 57 | +pub struct GarbgAnimation { | |
| 58 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 59 | + pub transition: Option<String>, | |
| 60 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 61 | + pub duration_ms: Option<u64>, | |
| 62 | +} | |
| 63 | + | |
| 64 | +impl GarbgConfig { | |
| 65 | + pub fn config_path() -> PathBuf { | |
| 66 | + config_dir().join("garbg").join("config.toml") | |
| 67 | + } | |
| 68 | + | |
| 69 | + pub fn load() -> Result<Self> { | |
| 70 | + let path = Self::config_path(); | |
| 71 | + if path.exists() { | |
| 72 | + let content = fs::read_to_string(&path) | |
| 73 | + .with_context(|| format!("Failed to read {}", path.display()))?; | |
| 74 | + toml::from_str(&content) | |
| 75 | + .with_context(|| format!("Failed to parse {}", path.display())) | |
| 76 | + } else { | |
| 77 | + Ok(Self::default()) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn save(&self) -> Result<()> { | |
| 82 | + let path = Self::config_path(); | |
| 83 | + if let Some(parent) = path.parent() { | |
| 84 | + fs::create_dir_all(parent) | |
| 85 | + .with_context(|| format!("Failed to create {}", parent.display()))?; | |
| 86 | + } | |
| 87 | + let content = toml::to_string_pretty(self) | |
| 88 | + .context("Failed to serialize garbg config")?; | |
| 89 | + fs::write(&path, content) | |
| 90 | + .with_context(|| format!("Failed to write {}", path.display())) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// garlock configuration | |
| 95 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 96 | +pub struct GarlockConfig { | |
| 97 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 98 | + pub idle_timeout_secs: Option<u64>, | |
| 99 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 100 | + pub blur: Option<GarlockBlur>, | |
| 101 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 102 | + pub clock: Option<GarlockClock>, | |
| 103 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 104 | + pub background: Option<String>, | |
| 105 | +} | |
| 106 | + | |
| 107 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 108 | +pub struct GarlockBlur { | |
| 109 | + pub enabled: bool, | |
| 110 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 111 | + pub radius: Option<u32>, | |
| 112 | +} | |
| 113 | + | |
| 114 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 115 | +pub struct GarlockClock { | |
| 116 | + pub enabled: bool, | |
| 117 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 118 | + pub format: Option<String>, | |
| 119 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 120 | + pub font_size: Option<u32>, | |
| 121 | +} | |
| 122 | + | |
| 123 | +impl GarlockConfig { | |
| 124 | + pub fn config_path() -> PathBuf { | |
| 125 | + config_dir().join("garlock").join("config.toml") | |
| 126 | + } | |
| 127 | + | |
| 128 | + pub fn load() -> Result<Self> { | |
| 129 | + let path = Self::config_path(); | |
| 130 | + if path.exists() { | |
| 131 | + let content = fs::read_to_string(&path) | |
| 132 | + .with_context(|| format!("Failed to read {}", path.display()))?; | |
| 133 | + toml::from_str(&content) | |
| 134 | + .with_context(|| format!("Failed to parse {}", path.display())) | |
| 135 | + } else { | |
| 136 | + Ok(Self::default()) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + pub fn save(&self) -> Result<()> { | |
| 141 | + let path = Self::config_path(); | |
| 142 | + if let Some(parent) = path.parent() { | |
| 143 | + fs::create_dir_all(parent) | |
| 144 | + .with_context(|| format!("Failed to create {}", parent.display()))?; | |
| 145 | + } | |
| 146 | + let content = toml::to_string_pretty(self) | |
| 147 | + .context("Failed to serialize garlock config")?; | |
| 148 | + fs::write(&path, content) | |
| 149 | + .with_context(|| format!("Failed to write {}", path.display())) | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +/// garshot configuration | |
| 154 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 155 | +pub struct GarshotConfig { | |
| 156 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 157 | + pub format: Option<String>, | |
| 158 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 159 | + pub save_path: Option<String>, | |
| 160 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 161 | + pub default_region: Option<String>, | |
| 162 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 163 | + pub copy_to_clipboard: Option<bool>, | |
| 164 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 165 | + pub show_notification: Option<bool>, | |
| 166 | +} | |
| 167 | + | |
| 168 | +impl GarshotConfig { | |
| 169 | + pub fn config_path() -> PathBuf { | |
| 170 | + config_dir().join("garshot").join("config.toml") | |
| 171 | + } | |
| 172 | + | |
| 173 | + pub fn load() -> Result<Self> { | |
| 174 | + let path = Self::config_path(); | |
| 175 | + if path.exists() { | |
| 176 | + let content = fs::read_to_string(&path) | |
| 177 | + .with_context(|| format!("Failed to read {}", path.display()))?; | |
| 178 | + toml::from_str(&content) | |
| 179 | + .with_context(|| format!("Failed to parse {}", path.display())) | |
| 180 | + } else { | |
| 181 | + Ok(Self::default()) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + pub fn save(&self) -> Result<()> { | |
| 186 | + let path = Self::config_path(); | |
| 187 | + if let Some(parent) = path.parent() { | |
| 188 | + fs::create_dir_all(parent) | |
| 189 | + .with_context(|| format!("Failed to create {}", parent.display()))?; | |
| 190 | + } | |
| 191 | + let content = toml::to_string_pretty(self) | |
| 192 | + .context("Failed to serialize garshot config")?; | |
| 193 | + fs::write(&path, content) | |
| 194 | + .with_context(|| format!("Failed to write {}", path.display())) | |
| 195 | + } | |
| 196 | +} | |
| 197 | + | |
| 198 | +/// garclip configuration | |
| 199 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 200 | +pub struct GarclipConfig { | |
| 201 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 202 | + pub max_history: Option<usize>, | |
| 203 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 204 | + pub persist_history: Option<bool>, | |
| 205 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 206 | + pub ignore_primary: Option<bool>, | |
| 207 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 208 | + pub deduplicate: Option<bool>, | |
| 209 | +} | |
| 210 | + | |
| 211 | +impl GarclipConfig { | |
| 212 | + pub fn config_path() -> PathBuf { | |
| 213 | + config_dir().join("garclip").join("config.toml") | |
| 214 | + } | |
| 215 | + | |
| 216 | + pub fn load() -> Result<Self> { | |
| 217 | + let path = Self::config_path(); | |
| 218 | + if path.exists() { | |
| 219 | + let content = fs::read_to_string(&path) | |
| 220 | + .with_context(|| format!("Failed to read {}", path.display()))?; | |
| 221 | + toml::from_str(&content) | |
| 222 | + .with_context(|| format!("Failed to parse {}", path.display())) | |
| 223 | + } else { | |
| 224 | + Ok(Self::default()) | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + pub fn save(&self) -> Result<()> { | |
| 229 | + let path = Self::config_path(); | |
| 230 | + if let Some(parent) = path.parent() { | |
| 231 | + fs::create_dir_all(parent) | |
| 232 | + .with_context(|| format!("Failed to create {}", parent.display()))?; | |
| 233 | + } | |
| 234 | + let content = toml::to_string_pretty(self) | |
| 235 | + .context("Failed to serialize garclip config")?; | |
| 236 | + fs::write(&path, content) | |
| 237 | + .with_context(|| format!("Failed to write {}", path.display())) | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +#[cfg(test)] | |
| 242 | +mod tests { | |
| 243 | + use super::*; | |
| 244 | + | |
| 245 | + #[test] | |
| 246 | + fn test_garbg_serialize() { | |
| 247 | + let config = GarbgConfig { | |
| 248 | + fit: Some("fill".to_string()), | |
| 249 | + color: Some("#282a36".to_string()), | |
| 250 | + slideshow: Some(GarbgSlideshow { | |
| 251 | + enabled: true, | |
| 252 | + interval_secs: Some(300), | |
| 253 | + shuffle: Some(true), | |
| 254 | + }), | |
| 255 | + ..Default::default() | |
| 256 | + }; | |
| 257 | + let toml = toml::to_string_pretty(&config).unwrap(); | |
| 258 | + assert!(toml.contains("fit = \"fill\"")); | |
| 259 | + assert!(toml.contains("[slideshow]")); | |
| 260 | + } | |
| 261 | + | |
| 262 | + #[test] | |
| 263 | + fn test_garlock_serialize() { | |
| 264 | + let config = GarlockConfig { | |
| 265 | + idle_timeout_secs: Some(300), | |
| 266 | + blur: Some(GarlockBlur { | |
| 267 | + enabled: true, | |
| 268 | + radius: Some(10), | |
| 269 | + }), | |
| 270 | + ..Default::default() | |
| 271 | + }; | |
| 272 | + let toml = toml::to_string_pretty(&config).unwrap(); | |
| 273 | + assert!(toml.contains("idle_timeout_secs = 300")); | |
| 274 | + assert!(toml.contains("[blur]")); | |
| 275 | + } | |
| 276 | +} | |
gargears/src/daemon.rsadded@@ -0,0 +1,165 @@ | ||
| 1 | +//! Daemon mode for gargears | |
| 2 | +//! | |
| 3 | +//! Provides IPC server functionality for controlling gargears from external tools. | |
| 4 | + | |
| 5 | +use anyhow::{Context, Result}; | |
| 6 | +use gargears_ipc::{Command, DaemonStatus, Response}; | |
| 7 | +use std::io::{BufRead, BufReader, Write}; | |
| 8 | +use std::os::unix::net::{UnixListener, UnixStream}; | |
| 9 | +use std::path::PathBuf; | |
| 10 | +use std::process::Command as ProcessCommand; | |
| 11 | +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; | |
| 12 | + | |
| 13 | +/// Commands sent from IPC server to main app | |
| 14 | +#[derive(Debug)] | |
| 15 | +pub enum DaemonCommand { | |
| 16 | + Show, | |
| 17 | + Hide, | |
| 18 | + Toggle, | |
| 19 | + Status, | |
| 20 | + Quit, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// IPC server for daemon mode | |
| 24 | +pub struct IpcServer { | |
| 25 | + listener: UnixListener, | |
| 26 | + socket_path: PathBuf, | |
| 27 | + command_tx: Sender<DaemonCommand>, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl IpcServer { | |
| 31 | + /// Create a new IPC server | |
| 32 | + pub fn new(command_tx: Sender<DaemonCommand>) -> Result<Self> { | |
| 33 | + let socket_path = gargears_ipc::socket_path(); | |
| 34 | + | |
| 35 | + // Remove existing socket if present | |
| 36 | + if socket_path.exists() { | |
| 37 | + std::fs::remove_file(&socket_path) | |
| 38 | + .with_context(|| format!("Failed to remove existing socket: {:?}", socket_path))?; | |
| 39 | + } | |
| 40 | + | |
| 41 | + let listener = UnixListener::bind(&socket_path) | |
| 42 | + .with_context(|| format!("Failed to bind to socket: {:?}", socket_path))?; | |
| 43 | + | |
| 44 | + // Set non-blocking so we can check for shutdown | |
| 45 | + listener.set_nonblocking(true)?; | |
| 46 | + | |
| 47 | + tracing::info!("IPC server listening on {:?}", socket_path); | |
| 48 | + | |
| 49 | + Ok(Self { | |
| 50 | + listener, | |
| 51 | + socket_path, | |
| 52 | + command_tx, | |
| 53 | + }) | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Run the server loop (should be called in a separate thread) | |
| 57 | + pub fn run(&self, visible: std::sync::Arc<std::sync::atomic::AtomicBool>) { | |
| 58 | + loop { | |
| 59 | + match self.listener.accept() { | |
| 60 | + Ok((stream, _)) => { | |
| 61 | + if let Err(e) = self.handle_client(stream, &visible) { | |
| 62 | + tracing::warn!("Error handling client: {}", e); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { | |
| 66 | + // No connection, sleep briefly | |
| 67 | + std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 68 | + } | |
| 69 | + Err(e) => { | |
| 70 | + tracing::error!("Accept error: {}", e); | |
| 71 | + break; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + fn handle_client( | |
| 78 | + &self, | |
| 79 | + mut stream: UnixStream, | |
| 80 | + visible: &std::sync::Arc<std::sync::atomic::AtomicBool>, | |
| 81 | + ) -> Result<()> { | |
| 82 | + stream.set_nonblocking(false)?; | |
| 83 | + | |
| 84 | + let mut reader = BufReader::new(stream.try_clone()?); | |
| 85 | + let mut line = String::new(); | |
| 86 | + | |
| 87 | + if reader.read_line(&mut line)? == 0 { | |
| 88 | + return Ok(()); | |
| 89 | + } | |
| 90 | + | |
| 91 | + let command: Command = serde_json::from_str(line.trim()) | |
| 92 | + .with_context(|| format!("Failed to parse command: {}", line.trim()))?; | |
| 93 | + | |
| 94 | + let response = match command { | |
| 95 | + Command::Show => { | |
| 96 | + self.command_tx.send(DaemonCommand::Show)?; | |
| 97 | + Response::ok() | |
| 98 | + } | |
| 99 | + Command::Hide => { | |
| 100 | + self.command_tx.send(DaemonCommand::Hide)?; | |
| 101 | + Response::ok() | |
| 102 | + } | |
| 103 | + Command::Toggle => { | |
| 104 | + self.command_tx.send(DaemonCommand::Toggle)?; | |
| 105 | + Response::ok() | |
| 106 | + } | |
| 107 | + Command::Status => { | |
| 108 | + let status = DaemonStatus { | |
| 109 | + visible: visible.load(std::sync::atomic::Ordering::Relaxed), | |
| 110 | + running: true, | |
| 111 | + }; | |
| 112 | + Response::ok_with_data(serde_json::to_value(status)?) | |
| 113 | + } | |
| 114 | + Command::Quit => { | |
| 115 | + self.command_tx.send(DaemonCommand::Quit)?; | |
| 116 | + Response::ok() | |
| 117 | + } | |
| 118 | + }; | |
| 119 | + | |
| 120 | + let response_str = serde_json::to_string(&response)?; | |
| 121 | + stream.write_all(response_str.as_bytes())?; | |
| 122 | + stream.write_all(b"\n")?; | |
| 123 | + stream.flush()?; | |
| 124 | + | |
| 125 | + Ok(()) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +impl Drop for IpcServer { | |
| 130 | + fn drop(&mut self) { | |
| 131 | + let _ = std::fs::remove_file(&self.socket_path); | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +/// Create command channel for daemon mode | |
| 136 | +pub fn create_command_channel() -> (Sender<DaemonCommand>, Receiver<DaemonCommand>) { | |
| 137 | + mpsc::channel() | |
| 138 | +} | |
| 139 | + | |
| 140 | +/// Check for pending daemon commands (non-blocking) | |
| 141 | +pub fn check_command(rx: &Receiver<DaemonCommand>) -> Option<DaemonCommand> { | |
| 142 | + match rx.try_recv() { | |
| 143 | + Ok(cmd) => Some(cmd), | |
| 144 | + Err(TryRecvError::Empty) => None, | |
| 145 | + Err(TryRecvError::Disconnected) => Some(DaemonCommand::Quit), | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +/// Send a desktop notification | |
| 150 | +pub fn notify(summary: &str, body: &str) { | |
| 151 | + let _ = ProcessCommand::new("notify-send") | |
| 152 | + .arg("--app-name=gargears") | |
| 153 | + .arg("--icon=preferences-system") | |
| 154 | + .arg(summary) | |
| 155 | + .arg(body) | |
| 156 | + .spawn(); | |
| 157 | +} | |
| 158 | + | |
| 159 | +/// Send startup notification | |
| 160 | +pub fn notify_startup() { | |
| 161 | + notify( | |
| 162 | + "gargears daemon started", | |
| 163 | + "Use 'gargearsctl show' or keybind to open settings", | |
| 164 | + ); | |
| 165 | +} | |
gargears/src/ipc/adapter.rsadded@@ -0,0 +1,50 @@ | ||
| 1 | +//! IPC adapter trait for communicating with gardesk daemons | |
| 2 | + | |
| 3 | +use crate::panels::Component; | |
| 4 | +use anyhow::Result; | |
| 5 | +use std::path::PathBuf; | |
| 6 | + | |
| 7 | +/// Status of a component | |
| 8 | +#[derive(Debug, Clone)] | |
| 9 | +pub struct ComponentStatus { | |
| 10 | + /// Whether the daemon is running | |
| 11 | + pub running: bool, | |
| 12 | + /// Current configuration values (component-specific) | |
| 13 | + pub config: serde_json::Value, | |
| 14 | +} | |
| 15 | + | |
| 16 | +/// Trait for IPC adapters to gardesk components | |
| 17 | +pub trait IpcAdapter: Send { | |
| 18 | + /// Get the component this adapter is for | |
| 19 | + fn component(&self) -> Component; | |
| 20 | + | |
| 21 | + /// Get the socket path for this component | |
| 22 | + fn socket_path(&self) -> PathBuf; | |
| 23 | + | |
| 24 | + /// Connect to the daemon | |
| 25 | + fn connect(&mut self) -> Result<()>; | |
| 26 | + | |
| 27 | + /// Disconnect from the daemon | |
| 28 | + fn disconnect(&mut self); | |
| 29 | + | |
| 30 | + /// Check if connected | |
| 31 | + fn is_connected(&self) -> bool; | |
| 32 | + | |
| 33 | + /// Query the daemon's current status/config | |
| 34 | + fn status(&mut self) -> Result<ComponentStatus>; | |
| 35 | + | |
| 36 | + /// Send a command to the daemon | |
| 37 | + fn send_command(&mut self, command: &str, args: serde_json::Value) -> Result<serde_json::Value>; | |
| 38 | + | |
| 39 | + /// Subscribe to events from the daemon (if supported) | |
| 40 | + fn subscribe(&mut self, _events: &[&str]) -> Result<()> { | |
| 41 | + // Default: no-op for daemons that don't support subscriptions | |
| 42 | + Ok(()) | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Check for pending events (non-blocking) | |
| 46 | + fn poll_events(&mut self) -> Vec<serde_json::Value> { | |
| 47 | + // Default: no events | |
| 48 | + Vec::new() | |
| 49 | + } | |
| 50 | +} | |
gargears/src/ipc/adapters/gar.rsadded@@ -0,0 +1,288 @@ | ||
| 1 | +//! IPC adapter for gar (window manager) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::{json, Value}; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Request format for gar IPC | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +struct GarRequest { | |
| 14 | + command: String, | |
| 15 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 16 | + args: Option<Value>, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Adapter for gar window manager | |
| 20 | +pub struct GarAdapter { | |
| 21 | + client: IpcClient, | |
| 22 | + subscribed: bool, | |
| 23 | +} | |
| 24 | + | |
| 25 | +impl GarAdapter { | |
| 26 | + pub fn new() -> Self { | |
| 27 | + use std::time::Duration; | |
| 28 | + Self { | |
| 29 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 30 | + subscribed: false, | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub fn socket_path(&self) -> PathBuf { | |
| 35 | + socket_path_for(Component::Gar) | |
| 36 | + } | |
| 37 | + | |
| 38 | + pub fn connect(&mut self) -> Result<()> { | |
| 39 | + self.client.connect(&self.socket_path()) | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Attempt reconnection with exponential backoff | |
| 43 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 44 | + self.subscribed = false; | |
| 45 | + self.client.reconnect() | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Check if we should attempt reconnection (backoff elapsed) | |
| 49 | + pub fn should_reconnect(&self) -> bool { | |
| 50 | + self.client.should_attempt_reconnect() | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Reset backoff state (e.g., on manual refresh) | |
| 54 | + pub fn reset_backoff(&mut self) { | |
| 55 | + self.client.reset_backoff(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + pub fn disconnect(&mut self) { | |
| 59 | + self.client.disconnect(); | |
| 60 | + self.subscribed = false; | |
| 61 | + } | |
| 62 | + | |
| 63 | + pub fn is_connected(&self) -> bool { | |
| 64 | + self.client.is_connected() | |
| 65 | + } | |
| 66 | + | |
| 67 | + /// Send a command to gar | |
| 68 | + fn send_command(&mut self, command: &str, args: Option<Value>) -> Result<Option<Value>> { | |
| 69 | + let request = GarRequest { | |
| 70 | + command: command.to_string(), | |
| 71 | + args, | |
| 72 | + }; | |
| 73 | + | |
| 74 | + let response: StandardResponse = self.client.send_receive(&request)?; | |
| 75 | + response.into_result() | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// Get gar status | |
| 79 | + pub fn status(&mut self) -> Result<GarStatus> { | |
| 80 | + // Query workspaces | |
| 81 | + let workspaces = self.send_command("workspaces", None)?; | |
| 82 | + | |
| 83 | + // Query focused window | |
| 84 | + let focused = self.send_command("focused", None)?; | |
| 85 | + | |
| 86 | + // Query config values | |
| 87 | + let config = self.query_config()?; | |
| 88 | + | |
| 89 | + Ok(GarStatus { | |
| 90 | + workspaces: workspaces.unwrap_or(Value::Array(vec![])), | |
| 91 | + focused_window: focused, | |
| 92 | + config, | |
| 93 | + }) | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// Query configuration values | |
| 97 | + pub fn query_config(&mut self) -> Result<GarConfig> { | |
| 98 | + // These are the main configurable values | |
| 99 | + let border_width = self.get_config_value("border_width")?; | |
| 100 | + let gap_inner = self.get_config_value("gap_inner")?; | |
| 101 | + let gap_outer = self.get_config_value("gap_outer")?; | |
| 102 | + let border_color_focused = self.get_config_value("border_color_focused")?; | |
| 103 | + let border_color_unfocused = self.get_config_value("border_color_unfocused")?; | |
| 104 | + let focus_follows_mouse = self.get_config_value("focus_follows_mouse")?; | |
| 105 | + let mouse_follows_focus = self.get_config_value("mouse_follows_focus")?; | |
| 106 | + let titlebar_enabled = self.get_config_value("titlebar_enabled")?; | |
| 107 | + let titlebar_height = self.get_config_value("titlebar_height")?; | |
| 108 | + | |
| 109 | + Ok(GarConfig { | |
| 110 | + border_width: border_width.and_then(|v| v.as_u64()).map(|v| v as u32), | |
| 111 | + gap_inner: gap_inner.and_then(|v| v.as_u64()).map(|v| v as u32), | |
| 112 | + gap_outer: gap_outer.and_then(|v| v.as_u64()).map(|v| v as u32), | |
| 113 | + border_color_focused: border_color_focused.and_then(|v| v.as_str().map(String::from)), | |
| 114 | + border_color_unfocused: border_color_unfocused | |
| 115 | + .and_then(|v| v.as_str().map(String::from)), | |
| 116 | + focus_follows_mouse: focus_follows_mouse.and_then(|v| v.as_bool()), | |
| 117 | + mouse_follows_focus: mouse_follows_focus.and_then(|v| v.as_bool()), | |
| 118 | + titlebar_enabled: titlebar_enabled.and_then(|v| v.as_bool()), | |
| 119 | + titlebar_height: titlebar_height.and_then(|v| v.as_u64()).map(|v| v as u32), | |
| 120 | + }) | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// Get a single config value | |
| 124 | + fn get_config_value(&mut self, key: &str) -> Result<Option<Value>> { | |
| 125 | + self.send_command("get", Some(json!({ "key": key }))) | |
| 126 | + } | |
| 127 | + | |
| 128 | + /// Set a config value | |
| 129 | + pub fn set_config_value(&mut self, key: &str, value: Value) -> Result<()> { | |
| 130 | + self.send_command("set", Some(json!({ "key": key, "value": value })))?; | |
| 131 | + Ok(()) | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Query keybinds | |
| 135 | + pub fn query_keybinds(&mut self) -> Result<Vec<GarKeybind>> { | |
| 136 | + let result = self.send_command("keybinds", None)?; | |
| 137 | + if let Some(data) = result { | |
| 138 | + Ok(serde_json::from_value(data).unwrap_or_default()) | |
| 139 | + } else { | |
| 140 | + Ok(vec![]) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + /// Query window rules | |
| 145 | + pub fn query_rules(&mut self) -> Result<Vec<GarRule>> { | |
| 146 | + let result = self.send_command("rules", None)?; | |
| 147 | + if let Some(data) = result { | |
| 148 | + Ok(serde_json::from_value(data).unwrap_or_default()) | |
| 149 | + } else { | |
| 150 | + Ok(vec![]) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 154 | + /// Subscribe to events | |
| 155 | + pub fn subscribe(&mut self, events: &[&str]) -> Result<()> { | |
| 156 | + let events: Vec<String> = events.iter().map(|s| s.to_string()).collect(); | |
| 157 | + self.send_command("subscribe", Some(json!({ "events": events })))?; | |
| 158 | + self.subscribed = true; | |
| 159 | + Ok(()) | |
| 160 | + } | |
| 161 | + | |
| 162 | + /// Poll for events (non-blocking) | |
| 163 | + pub fn poll_events(&mut self) -> Vec<GarEvent> { | |
| 164 | + let mut events = Vec::new(); | |
| 165 | + | |
| 166 | + while let Some(line) = self.client.try_read_line() { | |
| 167 | + if let Ok(event) = serde_json::from_str::<GarEventRaw>(&line) { | |
| 168 | + events.push(GarEvent { | |
| 169 | + event_type: event.event, | |
| 170 | + data: event.data, | |
| 171 | + }); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + events | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +impl Default for GarAdapter { | |
| 180 | + fn default() -> Self { | |
| 181 | + Self::new() | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +/// Gar status information | |
| 186 | +#[derive(Debug, Clone)] | |
| 187 | +pub struct GarStatus { | |
| 188 | + pub workspaces: Value, | |
| 189 | + pub focused_window: Option<Value>, | |
| 190 | + pub config: GarConfig, | |
| 191 | +} | |
| 192 | + | |
| 193 | +/// Gar configuration values | |
| 194 | +#[derive(Debug, Clone, Default)] | |
| 195 | +pub struct GarConfig { | |
| 196 | + pub border_width: Option<u32>, | |
| 197 | + pub gap_inner: Option<u32>, | |
| 198 | + pub gap_outer: Option<u32>, | |
| 199 | + pub border_color_focused: Option<String>, | |
| 200 | + pub border_color_unfocused: Option<String>, | |
| 201 | + pub focus_follows_mouse: Option<bool>, | |
| 202 | + pub mouse_follows_focus: Option<bool>, | |
| 203 | + pub titlebar_enabled: Option<bool>, | |
| 204 | + pub titlebar_height: Option<u32>, | |
| 205 | +} | |
| 206 | + | |
| 207 | +/// Raw event from gar | |
| 208 | +#[derive(Debug, Deserialize)] | |
| 209 | +struct GarEventRaw { | |
| 210 | + event: String, | |
| 211 | + data: Value, | |
| 212 | +} | |
| 213 | + | |
| 214 | +/// Parsed gar event | |
| 215 | +#[derive(Debug, Clone)] | |
| 216 | +pub struct GarEvent { | |
| 217 | + pub event_type: String, | |
| 218 | + pub data: Value, | |
| 219 | +} | |
| 220 | + | |
| 221 | +/// A keybind from gar | |
| 222 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 223 | +pub struct GarKeybind { | |
| 224 | + #[serde(default)] | |
| 225 | + pub key: String, | |
| 226 | + #[serde(default)] | |
| 227 | + pub modifiers: Vec<String>, | |
| 228 | + #[serde(default)] | |
| 229 | + pub action: String, | |
| 230 | +} | |
| 231 | + | |
| 232 | +impl GarKeybind { | |
| 233 | + /// Format as display string (e.g., "mod+Return → exec kitty") | |
| 234 | + pub fn display(&self) -> String { | |
| 235 | + let mods = self.modifiers.join("+"); | |
| 236 | + if mods.is_empty() { | |
| 237 | + format!("{} → {}", self.key, self.action) | |
| 238 | + } else { | |
| 239 | + format!("{}+{} → {}", mods, self.key, self.action) | |
| 240 | + } | |
| 241 | + } | |
| 242 | +} | |
| 243 | + | |
| 244 | +/// A window rule from gar | |
| 245 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 246 | +pub struct GarRule { | |
| 247 | + #[serde(default)] | |
| 248 | + pub class: Option<String>, | |
| 249 | + #[serde(default)] | |
| 250 | + pub title: Option<String>, | |
| 251 | + #[serde(default)] | |
| 252 | + pub workspace: Option<u32>, | |
| 253 | + #[serde(default)] | |
| 254 | + pub floating: Option<bool>, | |
| 255 | +} | |
| 256 | + | |
| 257 | +impl GarRule { | |
| 258 | + /// Format as display string | |
| 259 | + pub fn display(&self) -> String { | |
| 260 | + let mut matcher = Vec::new(); | |
| 261 | + if let Some(ref c) = self.class { | |
| 262 | + matcher.push(format!("class={}", c)); | |
| 263 | + } | |
| 264 | + if let Some(ref t) = self.title { | |
| 265 | + matcher.push(format!("title={}", t)); | |
| 266 | + } | |
| 267 | + let match_str = if matcher.is_empty() { | |
| 268 | + "*".to_string() | |
| 269 | + } else { | |
| 270 | + matcher.join(", ") | |
| 271 | + }; | |
| 272 | + | |
| 273 | + let mut actions = Vec::new(); | |
| 274 | + if let Some(ws) = self.workspace { | |
| 275 | + actions.push(format!("ws={}", ws)); | |
| 276 | + } | |
| 277 | + if let Some(true) = self.floating { | |
| 278 | + actions.push("floating".to_string()); | |
| 279 | + } | |
| 280 | + let action_str = if actions.is_empty() { | |
| 281 | + "none".to_string() | |
| 282 | + } else { | |
| 283 | + actions.join(", ") | |
| 284 | + }; | |
| 285 | + | |
| 286 | + format!("{} → {}", match_str, action_str) | |
| 287 | + } | |
| 288 | +} | |
gargears/src/ipc/adapters/garbar.rsadded@@ -0,0 +1,141 @@ | ||
| 1 | +//! IPC adapter for garbar (status bar) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garbar | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarbarCommand { | |
| 15 | + Show, | |
| 16 | + Hide, | |
| 17 | + Toggle, | |
| 18 | + Reload, | |
| 19 | + Quit, | |
| 20 | + Status, | |
| 21 | + UpdateModule { module: String }, | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Adapter for garbar status bar | |
| 25 | +pub struct GarbarAdapter { | |
| 26 | + client: IpcClient, | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl GarbarAdapter { | |
| 30 | + pub fn new() -> Self { | |
| 31 | + use std::time::Duration; | |
| 32 | + Self { | |
| 33 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + pub fn socket_path(&self) -> PathBuf { | |
| 38 | + socket_path_for(Component::Garbar) | |
| 39 | + } | |
| 40 | + | |
| 41 | + pub fn connect(&mut self) -> Result<()> { | |
| 42 | + self.client.connect(&self.socket_path()) | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Attempt reconnection with exponential backoff | |
| 46 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 47 | + self.client.reconnect() | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Check if we should attempt reconnection | |
| 51 | + pub fn should_reconnect(&self) -> bool { | |
| 52 | + self.client.should_attempt_reconnect() | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Reset backoff state | |
| 56 | + pub fn reset_backoff(&mut self) { | |
| 57 | + self.client.reset_backoff(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + pub fn disconnect(&mut self) { | |
| 61 | + self.client.disconnect(); | |
| 62 | + } | |
| 63 | + | |
| 64 | + pub fn is_connected(&self) -> bool { | |
| 65 | + self.client.is_connected() | |
| 66 | + } | |
| 67 | + | |
| 68 | + /// Send a command | |
| 69 | + fn send_command(&mut self, command: GarbarCommand) -> Result<Option<Value>> { | |
| 70 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 71 | + response.into_result() | |
| 72 | + } | |
| 73 | + | |
| 74 | + /// Get status | |
| 75 | + pub fn status(&mut self) -> Result<GarbarStatus> { | |
| 76 | + let data = self.send_command(GarbarCommand::Status)?; | |
| 77 | + | |
| 78 | + if let Some(data) = data { | |
| 79 | + Ok(serde_json::from_value(data)?) | |
| 80 | + } else { | |
| 81 | + Ok(GarbarStatus::default()) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Show the bar | |
| 86 | + pub fn show(&mut self) -> Result<()> { | |
| 87 | + self.send_command(GarbarCommand::Show)?; | |
| 88 | + Ok(()) | |
| 89 | + } | |
| 90 | + | |
| 91 | + /// Hide the bar | |
| 92 | + pub fn hide(&mut self) -> Result<()> { | |
| 93 | + self.send_command(GarbarCommand::Hide)?; | |
| 94 | + Ok(()) | |
| 95 | + } | |
| 96 | + | |
| 97 | + /// Toggle visibility | |
| 98 | + pub fn toggle(&mut self) -> Result<()> { | |
| 99 | + self.send_command(GarbarCommand::Toggle)?; | |
| 100 | + Ok(()) | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// Reload configuration | |
| 104 | + pub fn reload(&mut self) -> Result<()> { | |
| 105 | + self.send_command(GarbarCommand::Reload)?; | |
| 106 | + Ok(()) | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Update a specific module | |
| 110 | + pub fn update_module(&mut self, module: &str) -> Result<()> { | |
| 111 | + self.send_command(GarbarCommand::UpdateModule { | |
| 112 | + module: module.to_string(), | |
| 113 | + })?; | |
| 114 | + Ok(()) | |
| 115 | + } | |
| 116 | +} | |
| 117 | + | |
| 118 | +impl Default for GarbarAdapter { | |
| 119 | + fn default() -> Self { | |
| 120 | + Self::new() | |
| 121 | + } | |
| 122 | +} | |
| 123 | + | |
| 124 | +/// Garbar status | |
| 125 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 126 | +pub struct GarbarStatus { | |
| 127 | + #[serde(default)] | |
| 128 | + pub visible: bool, | |
| 129 | + #[serde(default)] | |
| 130 | + pub width: u16, | |
| 131 | + #[serde(default)] | |
| 132 | + pub height: u16, | |
| 133 | + #[serde(default)] | |
| 134 | + pub position: Option<String>, | |
| 135 | + #[serde(default)] | |
| 136 | + pub modules_left: Vec<String>, | |
| 137 | + #[serde(default)] | |
| 138 | + pub modules_center: Vec<String>, | |
| 139 | + #[serde(default)] | |
| 140 | + pub modules_right: Vec<String>, | |
| 141 | +} | |
gargears/src/ipc/adapters/garbg.rsadded@@ -0,0 +1,211 @@ | ||
| 1 | +//! IPC adapter for garbg (wallpaper daemon) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garbg (uses serde tag) | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarbgCommand { | |
| 15 | + Status, | |
| 16 | + Next { | |
| 17 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 18 | + monitor: Option<String>, | |
| 19 | + }, | |
| 20 | + Prev { | |
| 21 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 22 | + monitor: Option<String>, | |
| 23 | + }, | |
| 24 | + Pause, | |
| 25 | + Resume, | |
| 26 | + Toggle, | |
| 27 | + QueryMonitors, | |
| 28 | + QueryCurrent, | |
| 29 | + Subscribe { | |
| 30 | + events: Vec<String>, | |
| 31 | + }, | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// Adapter for garbg wallpaper daemon | |
| 35 | +pub struct GarbgAdapter { | |
| 36 | + client: IpcClient, | |
| 37 | + subscribed: bool, | |
| 38 | +} | |
| 39 | + | |
| 40 | +impl GarbgAdapter { | |
| 41 | + pub fn new() -> Self { | |
| 42 | + use std::time::Duration; | |
| 43 | + Self { | |
| 44 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 45 | + subscribed: false, | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + pub fn socket_path(&self) -> PathBuf { | |
| 50 | + socket_path_for(Component::Garbg) | |
| 51 | + } | |
| 52 | + | |
| 53 | + pub fn connect(&mut self) -> Result<()> { | |
| 54 | + self.client.connect(&self.socket_path()) | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// Attempt reconnection with exponential backoff | |
| 58 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 59 | + self.subscribed = false; | |
| 60 | + self.client.reconnect() | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Check if we should attempt reconnection | |
| 64 | + pub fn should_reconnect(&self) -> bool { | |
| 65 | + self.client.should_attempt_reconnect() | |
| 66 | + } | |
| 67 | + | |
| 68 | + /// Reset backoff state | |
| 69 | + pub fn reset_backoff(&mut self) { | |
| 70 | + self.client.reset_backoff(); | |
| 71 | + } | |
| 72 | + | |
| 73 | + pub fn disconnect(&mut self) { | |
| 74 | + self.client.disconnect(); | |
| 75 | + self.subscribed = false; | |
| 76 | + } | |
| 77 | + | |
| 78 | + pub fn is_connected(&self) -> bool { | |
| 79 | + self.client.is_connected() | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// Send a command | |
| 83 | + fn send_command(&mut self, command: GarbgCommand) -> Result<Option<Value>> { | |
| 84 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 85 | + response.into_result() | |
| 86 | + } | |
| 87 | + | |
| 88 | + /// Get status | |
| 89 | + pub fn status(&mut self) -> Result<GarbgStatus> { | |
| 90 | + let data = self.send_command(GarbgCommand::Status)?; | |
| 91 | + | |
| 92 | + if let Some(data) = data { | |
| 93 | + Ok(serde_json::from_value(data)?) | |
| 94 | + } else { | |
| 95 | + Ok(GarbgStatus::default()) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + /// Query current wallpaper info | |
| 100 | + pub fn query_current(&mut self) -> Result<Option<Value>> { | |
| 101 | + self.send_command(GarbgCommand::QueryCurrent) | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Query monitors | |
| 105 | + pub fn query_monitors(&mut self) -> Result<Option<Value>> { | |
| 106 | + self.send_command(GarbgCommand::QueryMonitors) | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Next wallpaper | |
| 110 | + pub fn next(&mut self, monitor: Option<String>) -> Result<()> { | |
| 111 | + self.send_command(GarbgCommand::Next { monitor })?; | |
| 112 | + Ok(()) | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Previous wallpaper | |
| 116 | + pub fn prev(&mut self, monitor: Option<String>) -> Result<()> { | |
| 117 | + self.send_command(GarbgCommand::Prev { monitor })?; | |
| 118 | + Ok(()) | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Pause slideshow/animation | |
| 122 | + pub fn pause(&mut self) -> Result<()> { | |
| 123 | + self.send_command(GarbgCommand::Pause)?; | |
| 124 | + Ok(()) | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Resume slideshow/animation | |
| 128 | + pub fn resume(&mut self) -> Result<()> { | |
| 129 | + self.send_command(GarbgCommand::Resume)?; | |
| 130 | + Ok(()) | |
| 131 | + } | |
| 132 | + | |
| 133 | + /// Toggle pause state | |
| 134 | + pub fn toggle(&mut self) -> Result<()> { | |
| 135 | + self.send_command(GarbgCommand::Toggle)?; | |
| 136 | + Ok(()) | |
| 137 | + } | |
| 138 | + | |
| 139 | + /// Subscribe to events | |
| 140 | + pub fn subscribe(&mut self, events: &[&str]) -> Result<()> { | |
| 141 | + let events: Vec<String> = events.iter().map(|s| s.to_string()).collect(); | |
| 142 | + self.send_command(GarbgCommand::Subscribe { events })?; | |
| 143 | + self.subscribed = true; | |
| 144 | + Ok(()) | |
| 145 | + } | |
| 146 | + | |
| 147 | + /// Poll for events | |
| 148 | + pub fn poll_events(&mut self) -> Vec<GarbgEvent> { | |
| 149 | + let mut events = Vec::new(); | |
| 150 | + | |
| 151 | + while let Some(line) = self.client.try_read_line() { | |
| 152 | + if let Ok(event) = serde_json::from_str::<GarbgEvent>(&line) { | |
| 153 | + events.push(event); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + events | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +impl Default for GarbgAdapter { | |
| 162 | + fn default() -> Self { | |
| 163 | + Self::new() | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +/// Garbg status | |
| 168 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 169 | +pub struct GarbgStatus { | |
| 170 | + #[serde(default)] | |
| 171 | + pub paused: bool, | |
| 172 | + #[serde(default)] | |
| 173 | + pub current_source: Option<String>, | |
| 174 | + #[serde(default)] | |
| 175 | + pub current_wallpaper: Option<String>, | |
| 176 | + #[serde(default)] | |
| 177 | + pub interval_secs: Option<u64>, | |
| 178 | + #[serde(default)] | |
| 179 | + pub playlist_index: Option<usize>, | |
| 180 | + #[serde(default)] | |
| 181 | + pub playlist_total: Option<usize>, | |
| 182 | +} | |
| 183 | + | |
| 184 | +/// Events from garbg | |
| 185 | +#[derive(Debug, Clone, Deserialize)] | |
| 186 | +#[serde(tag = "event", rename_all = "snake_case")] | |
| 187 | +pub enum GarbgEvent { | |
| 188 | + WallpaperChanged { | |
| 189 | + monitor: String, | |
| 190 | + source: String, | |
| 191 | + #[serde(default)] | |
| 192 | + workspace: Option<usize>, | |
| 193 | + }, | |
| 194 | + SourceUpdated { | |
| 195 | + source: String, | |
| 196 | + count: usize, | |
| 197 | + }, | |
| 198 | + AnimationState { | |
| 199 | + playing: bool, | |
| 200 | + }, | |
| 201 | + SlideshowAdvanced { | |
| 202 | + current: usize, | |
| 203 | + total: usize, | |
| 204 | + source: String, | |
| 205 | + }, | |
| 206 | + Error { | |
| 207 | + message: String, | |
| 208 | + #[serde(default)] | |
| 209 | + context: Option<String>, | |
| 210 | + }, | |
| 211 | +} | |
gargears/src/ipc/adapters/garclip.rsadded@@ -0,0 +1,111 @@ | ||
| 1 | +//! IPC adapter for garclip (clipboard manager) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garclip | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarclipCommand { | |
| 15 | + Status, | |
| 16 | + History { limit: usize }, | |
| 17 | + Clear, | |
| 18 | + ClearHistory { keep_pinned: bool }, | |
| 19 | + Reload, | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// Adapter for garclip clipboard manager | |
| 23 | +pub struct GarclipAdapter { | |
| 24 | + client: IpcClient, | |
| 25 | +} | |
| 26 | + | |
| 27 | +impl GarclipAdapter { | |
| 28 | + pub fn new() -> Self { | |
| 29 | + use std::time::Duration; | |
| 30 | + Self { | |
| 31 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + pub fn socket_path(&self) -> PathBuf { | |
| 36 | + socket_path_for(Component::Garclip) | |
| 37 | + } | |
| 38 | + | |
| 39 | + pub fn connect(&mut self) -> Result<()> { | |
| 40 | + self.client.connect(&self.socket_path()) | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Attempt reconnection with exponential backoff | |
| 44 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 45 | + self.client.reconnect() | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Check if we should attempt reconnection | |
| 49 | + pub fn should_reconnect(&self) -> bool { | |
| 50 | + self.client.should_attempt_reconnect() | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Reset backoff state | |
| 54 | + pub fn reset_backoff(&mut self) { | |
| 55 | + self.client.reset_backoff(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + pub fn disconnect(&mut self) { | |
| 59 | + self.client.disconnect(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + pub fn is_connected(&self) -> bool { | |
| 63 | + self.client.is_connected() | |
| 64 | + } | |
| 65 | + | |
| 66 | + fn send_command(&mut self, command: GarclipCommand) -> Result<Option<Value>> { | |
| 67 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 68 | + response.into_result() | |
| 69 | + } | |
| 70 | + | |
| 71 | + pub fn status(&mut self) -> Result<GarclipStatus> { | |
| 72 | + let data = self.send_command(GarclipCommand::Status)?; | |
| 73 | + if let Some(data) = data { | |
| 74 | + Ok(serde_json::from_value(data)?) | |
| 75 | + } else { | |
| 76 | + Ok(GarclipStatus::default()) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + pub fn history(&mut self, limit: usize) -> Result<Option<Value>> { | |
| 81 | + self.send_command(GarclipCommand::History { limit }) | |
| 82 | + } | |
| 83 | + | |
| 84 | + pub fn clear(&mut self) -> Result<()> { | |
| 85 | + self.send_command(GarclipCommand::Clear)?; | |
| 86 | + Ok(()) | |
| 87 | + } | |
| 88 | + | |
| 89 | + pub fn clear_history(&mut self, keep_pinned: bool) -> Result<()> { | |
| 90 | + self.send_command(GarclipCommand::ClearHistory { keep_pinned })?; | |
| 91 | + Ok(()) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +impl Default for GarclipAdapter { | |
| 96 | + fn default() -> Self { | |
| 97 | + Self::new() | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 102 | +pub struct GarclipStatus { | |
| 103 | + #[serde(default)] | |
| 104 | + pub history_count: usize, | |
| 105 | + #[serde(default)] | |
| 106 | + pub pinned_count: usize, | |
| 107 | + #[serde(default)] | |
| 108 | + pub owns_clipboard: bool, | |
| 109 | + #[serde(default)] | |
| 110 | + pub watching_primary: bool, | |
| 111 | +} | |
gargears/src/ipc/adapters/garfield.rsadded@@ -0,0 +1,103 @@ | ||
| 1 | +//! IPC adapter for garfield (file explorer) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garfield | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarfieldCommand { | |
| 15 | + Status, | |
| 16 | + CurrentDir, | |
| 17 | + Open { path: String }, | |
| 18 | +} | |
| 19 | + | |
| 20 | +/// Adapter for garfield file explorer | |
| 21 | +pub struct GarfieldAdapter { | |
| 22 | + client: IpcClient, | |
| 23 | +} | |
| 24 | + | |
| 25 | +impl GarfieldAdapter { | |
| 26 | + pub fn new() -> Self { | |
| 27 | + use std::time::Duration; | |
| 28 | + Self { | |
| 29 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + pub fn socket_path(&self) -> PathBuf { | |
| 34 | + socket_path_for(Component::Garfield) | |
| 35 | + } | |
| 36 | + | |
| 37 | + pub fn connect(&mut self) -> Result<()> { | |
| 38 | + self.client.connect(&self.socket_path()) | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Attempt reconnection with exponential backoff | |
| 42 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 43 | + self.client.reconnect() | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Check if we should attempt reconnection | |
| 47 | + pub fn should_reconnect(&self) -> bool { | |
| 48 | + self.client.should_attempt_reconnect() | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Reset backoff state | |
| 52 | + pub fn reset_backoff(&mut self) { | |
| 53 | + self.client.reset_backoff(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + pub fn disconnect(&mut self) { | |
| 57 | + self.client.disconnect(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + pub fn is_connected(&self) -> bool { | |
| 61 | + self.client.is_connected() | |
| 62 | + } | |
| 63 | + | |
| 64 | + fn send_command(&mut self, command: GarfieldCommand) -> Result<Option<Value>> { | |
| 65 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 66 | + response.into_result() | |
| 67 | + } | |
| 68 | + | |
| 69 | + pub fn status(&mut self) -> Result<GarfieldStatus> { | |
| 70 | + let data = self.send_command(GarfieldCommand::Status)?; | |
| 71 | + if let Some(data) = data { | |
| 72 | + Ok(serde_json::from_value(data)?) | |
| 73 | + } else { | |
| 74 | + Ok(GarfieldStatus::default()) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + pub fn current_dir(&mut self) -> Result<Option<String>> { | |
| 79 | + let data = self.send_command(GarfieldCommand::CurrentDir)?; | |
| 80 | + Ok(data.and_then(|v| v.as_str().map(String::from))) | |
| 81 | + } | |
| 82 | + | |
| 83 | + pub fn open(&mut self, path: &str) -> Result<()> { | |
| 84 | + self.send_command(GarfieldCommand::Open { | |
| 85 | + path: path.to_string(), | |
| 86 | + })?; | |
| 87 | + Ok(()) | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +impl Default for GarfieldAdapter { | |
| 92 | + fn default() -> Self { | |
| 93 | + Self::new() | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 98 | +pub struct GarfieldStatus { | |
| 99 | + #[serde(default)] | |
| 100 | + pub running: bool, | |
| 101 | + #[serde(default)] | |
| 102 | + pub current_dir: Option<String>, | |
| 103 | +} | |
gargears/src/ipc/adapters/garlaunch.rsadded@@ -0,0 +1,105 @@ | ||
| 1 | +//! IPC adapter for garlaunch (application launcher) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garlaunch | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +struct GarlaunchRequest { | |
| 14 | + command: String, | |
| 15 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 16 | + mode: Option<String>, | |
| 17 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 18 | + source: Option<String>, | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Adapter for garlaunch application launcher | |
| 22 | +pub struct GarlaunchAdapter { | |
| 23 | + client: IpcClient, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl GarlaunchAdapter { | |
| 27 | + pub fn new() -> Self { | |
| 28 | + use std::time::Duration; | |
| 29 | + Self { | |
| 30 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub fn socket_path(&self) -> PathBuf { | |
| 35 | + socket_path_for(Component::Garlaunch) | |
| 36 | + } | |
| 37 | + | |
| 38 | + pub fn connect(&mut self) -> Result<()> { | |
| 39 | + self.client.connect(&self.socket_path()) | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Attempt reconnection with exponential backoff | |
| 43 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 44 | + self.client.reconnect() | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Check if we should attempt reconnection | |
| 48 | + pub fn should_reconnect(&self) -> bool { | |
| 49 | + self.client.should_attempt_reconnect() | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Reset backoff state | |
| 53 | + pub fn reset_backoff(&mut self) { | |
| 54 | + self.client.reset_backoff(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + pub fn disconnect(&mut self) { | |
| 58 | + self.client.disconnect(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + pub fn is_connected(&self) -> bool { | |
| 62 | + self.client.is_connected() | |
| 63 | + } | |
| 64 | + | |
| 65 | + fn send_command(&mut self, command: &str, mode: Option<&str>) -> Result<Option<Value>> { | |
| 66 | + let request = GarlaunchRequest { | |
| 67 | + command: command.to_string(), | |
| 68 | + mode: mode.map(String::from), | |
| 69 | + source: None, | |
| 70 | + }; | |
| 71 | + let response: StandardResponse = self.client.send_receive(&request)?; | |
| 72 | + response.into_result() | |
| 73 | + } | |
| 74 | + | |
| 75 | + pub fn status(&mut self) -> Result<GarlaunchStatus> { | |
| 76 | + let data = self.send_command("status", None)?; | |
| 77 | + if let Some(data) = data { | |
| 78 | + Ok(serde_json::from_value(data)?) | |
| 79 | + } else { | |
| 80 | + Ok(GarlaunchStatus::default()) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + pub fn show(&mut self, mode: Option<&str>) -> Result<()> { | |
| 85 | + self.send_command("show", mode)?; | |
| 86 | + Ok(()) | |
| 87 | + } | |
| 88 | + | |
| 89 | + pub fn toggle(&mut self, mode: Option<&str>) -> Result<()> { | |
| 90 | + self.send_command("toggle", mode)?; | |
| 91 | + Ok(()) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +impl Default for GarlaunchAdapter { | |
| 96 | + fn default() -> Self { | |
| 97 | + Self::new() | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 102 | +pub struct GarlaunchStatus { | |
| 103 | + #[serde(default)] | |
| 104 | + pub daemon_running: bool, | |
| 105 | +} | |
gargears/src/ipc/adapters/garlock.rsadded@@ -0,0 +1,95 @@ | ||
| 1 | +//! IPC adapter for garlock (screen locker) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garlock | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarlockCommand { | |
| 15 | + Status, | |
| 16 | + Lock, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Adapter for garlock screen locker | |
| 20 | +pub struct GarlockAdapter { | |
| 21 | + client: IpcClient, | |
| 22 | +} | |
| 23 | + | |
| 24 | +impl GarlockAdapter { | |
| 25 | + pub fn new() -> Self { | |
| 26 | + use std::time::Duration; | |
| 27 | + Self { | |
| 28 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + pub fn socket_path(&self) -> PathBuf { | |
| 33 | + socket_path_for(Component::Garlock) | |
| 34 | + } | |
| 35 | + | |
| 36 | + pub fn connect(&mut self) -> Result<()> { | |
| 37 | + self.client.connect(&self.socket_path()) | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// Attempt reconnection with exponential backoff | |
| 41 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 42 | + self.client.reconnect() | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Check if we should attempt reconnection | |
| 46 | + pub fn should_reconnect(&self) -> bool { | |
| 47 | + self.client.should_attempt_reconnect() | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Reset backoff state | |
| 51 | + pub fn reset_backoff(&mut self) { | |
| 52 | + self.client.reset_backoff(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + pub fn disconnect(&mut self) { | |
| 56 | + self.client.disconnect(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + pub fn is_connected(&self) -> bool { | |
| 60 | + self.client.is_connected() | |
| 61 | + } | |
| 62 | + | |
| 63 | + fn send_command(&mut self, command: GarlockCommand) -> Result<Option<Value>> { | |
| 64 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 65 | + response.into_result() | |
| 66 | + } | |
| 67 | + | |
| 68 | + pub fn status(&mut self) -> Result<GarlockStatus> { | |
| 69 | + let data = self.send_command(GarlockCommand::Status)?; | |
| 70 | + if let Some(data) = data { | |
| 71 | + Ok(serde_json::from_value(data)?) | |
| 72 | + } else { | |
| 73 | + Ok(GarlockStatus::default()) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + pub fn lock(&mut self) -> Result<()> { | |
| 78 | + self.send_command(GarlockCommand::Lock)?; | |
| 79 | + Ok(()) | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +impl Default for GarlockAdapter { | |
| 84 | + fn default() -> Self { | |
| 85 | + Self::new() | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 90 | +pub struct GarlockStatus { | |
| 91 | + #[serde(default)] | |
| 92 | + pub locked: bool, | |
| 93 | + #[serde(default)] | |
| 94 | + pub idle_timeout_secs: Option<u64>, | |
| 95 | +} | |
gargears/src/ipc/adapters/garnotify.rsadded@@ -0,0 +1,121 @@ | ||
| 1 | +//! IPC adapter for garnotify (notification daemon) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garnotify | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarnotifyCommand { | |
| 15 | + Status, | |
| 16 | + Clear, | |
| 17 | + ClearAll, | |
| 18 | + Pause, | |
| 19 | + Resume, | |
| 20 | + Toggle, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// Adapter for garnotify notification daemon | |
| 24 | +pub struct GarnotifyAdapter { | |
| 25 | + client: IpcClient, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl GarnotifyAdapter { | |
| 29 | + pub fn new() -> Self { | |
| 30 | + use std::time::Duration; | |
| 31 | + Self { | |
| 32 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + pub fn socket_path(&self) -> PathBuf { | |
| 37 | + socket_path_for(Component::Garnotify) | |
| 38 | + } | |
| 39 | + | |
| 40 | + pub fn connect(&mut self) -> Result<()> { | |
| 41 | + self.client.connect(&self.socket_path()) | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Attempt reconnection with exponential backoff | |
| 45 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 46 | + self.client.reconnect() | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Check if we should attempt reconnection | |
| 50 | + pub fn should_reconnect(&self) -> bool { | |
| 51 | + self.client.should_attempt_reconnect() | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Reset backoff state | |
| 55 | + pub fn reset_backoff(&mut self) { | |
| 56 | + self.client.reset_backoff(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + pub fn disconnect(&mut self) { | |
| 60 | + self.client.disconnect(); | |
| 61 | + } | |
| 62 | + | |
| 63 | + pub fn is_connected(&self) -> bool { | |
| 64 | + self.client.is_connected() | |
| 65 | + } | |
| 66 | + | |
| 67 | + fn send_command(&mut self, command: GarnotifyCommand) -> Result<Option<Value>> { | |
| 68 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 69 | + response.into_result() | |
| 70 | + } | |
| 71 | + | |
| 72 | + pub fn status(&mut self) -> Result<GarnotifyStatus> { | |
| 73 | + let data = self.send_command(GarnotifyCommand::Status)?; | |
| 74 | + if let Some(data) = data { | |
| 75 | + Ok(serde_json::from_value(data)?) | |
| 76 | + } else { | |
| 77 | + Ok(GarnotifyStatus::default()) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn clear(&mut self) -> Result<()> { | |
| 82 | + self.send_command(GarnotifyCommand::Clear)?; | |
| 83 | + Ok(()) | |
| 84 | + } | |
| 85 | + | |
| 86 | + pub fn clear_all(&mut self) -> Result<()> { | |
| 87 | + self.send_command(GarnotifyCommand::ClearAll)?; | |
| 88 | + Ok(()) | |
| 89 | + } | |
| 90 | + | |
| 91 | + pub fn pause(&mut self) -> Result<()> { | |
| 92 | + self.send_command(GarnotifyCommand::Pause)?; | |
| 93 | + Ok(()) | |
| 94 | + } | |
| 95 | + | |
| 96 | + pub fn resume(&mut self) -> Result<()> { | |
| 97 | + self.send_command(GarnotifyCommand::Resume)?; | |
| 98 | + Ok(()) | |
| 99 | + } | |
| 100 | + | |
| 101 | + pub fn toggle(&mut self) -> Result<()> { | |
| 102 | + self.send_command(GarnotifyCommand::Toggle)?; | |
| 103 | + Ok(()) | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +impl Default for GarnotifyAdapter { | |
| 108 | + fn default() -> Self { | |
| 109 | + Self::new() | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 114 | +pub struct GarnotifyStatus { | |
| 115 | + #[serde(default)] | |
| 116 | + pub paused: bool, | |
| 117 | + #[serde(default)] | |
| 118 | + pub pending_count: usize, | |
| 119 | + #[serde(default)] | |
| 120 | + pub history_count: usize, | |
| 121 | +} | |
gargears/src/ipc/adapters/garshot.rsadded@@ -0,0 +1,92 @@ | ||
| 1 | +//! IPC adapter for garshot (screenshot tool) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for garshot | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GarshotCommand { | |
| 15 | + Status, | |
| 16 | + ListMonitors, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Adapter for garshot screenshot tool | |
| 20 | +pub struct GarshotAdapter { | |
| 21 | + client: IpcClient, | |
| 22 | +} | |
| 23 | + | |
| 24 | +impl GarshotAdapter { | |
| 25 | + pub fn new() -> Self { | |
| 26 | + use std::time::Duration; | |
| 27 | + Self { | |
| 28 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + pub fn socket_path(&self) -> PathBuf { | |
| 33 | + socket_path_for(Component::Garshot) | |
| 34 | + } | |
| 35 | + | |
| 36 | + pub fn connect(&mut self) -> Result<()> { | |
| 37 | + self.client.connect(&self.socket_path()) | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// Attempt reconnection with exponential backoff | |
| 41 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 42 | + self.client.reconnect() | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Check if we should attempt reconnection | |
| 46 | + pub fn should_reconnect(&self) -> bool { | |
| 47 | + self.client.should_attempt_reconnect() | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Reset backoff state | |
| 51 | + pub fn reset_backoff(&mut self) { | |
| 52 | + self.client.reset_backoff(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + pub fn disconnect(&mut self) { | |
| 56 | + self.client.disconnect(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + pub fn is_connected(&self) -> bool { | |
| 60 | + self.client.is_connected() | |
| 61 | + } | |
| 62 | + | |
| 63 | + fn send_command(&mut self, command: GarshotCommand) -> Result<Option<Value>> { | |
| 64 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 65 | + response.into_result() | |
| 66 | + } | |
| 67 | + | |
| 68 | + pub fn status(&mut self) -> Result<GarshotStatus> { | |
| 69 | + let data = self.send_command(GarshotCommand::Status)?; | |
| 70 | + if let Some(data) = data { | |
| 71 | + Ok(serde_json::from_value(data)?) | |
| 72 | + } else { | |
| 73 | + Ok(GarshotStatus::default()) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + pub fn list_monitors(&mut self) -> Result<Option<Value>> { | |
| 78 | + self.send_command(GarshotCommand::ListMonitors) | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 82 | +impl Default for GarshotAdapter { | |
| 83 | + fn default() -> Self { | |
| 84 | + Self::new() | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 89 | +pub struct GarshotStatus { | |
| 90 | + #[serde(default)] | |
| 91 | + pub ready: bool, | |
| 92 | +} | |
gargears/src/ipc/adapters/garterm.rsadded@@ -0,0 +1,155 @@ | ||
| 1 | +//! IPC adapter for garterm (terminal emulator) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::panels::Component; | |
| 5 | +use anyhow::Result; | |
| 6 | +use serde::{Deserialize, Serialize}; | |
| 7 | +use serde_json::Value; | |
| 8 | +use std::path::PathBuf; | |
| 9 | + | |
| 10 | +/// Commands for garterm | |
| 11 | +#[derive(Debug, Serialize)] | |
| 12 | +#[serde(tag = "cmd", rename_all = "snake_case")] | |
| 13 | +enum GartermCommand { | |
| 14 | + Status, | |
| 15 | + NewWindow, | |
| 16 | + NewTab, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Adapter for garterm terminal emulator | |
| 20 | +/// | |
| 21 | +/// Note: garterm uses per-instance sockets (garterm-{PID}.sock) | |
| 22 | +/// We connect to the focused instance via the "focused" file | |
| 23 | +pub struct GartermAdapter { | |
| 24 | + client: IpcClient, | |
| 25 | + focused_pid: Option<u32>, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl GartermAdapter { | |
| 29 | + pub fn new() -> Self { | |
| 30 | + use std::time::Duration; | |
| 31 | + Self { | |
| 32 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 33 | + focused_pid: None, | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// Get the directory containing garterm sockets | |
| 38 | + fn socket_dir() -> PathBuf { | |
| 39 | + let runtime_dir = | |
| 40 | + std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); | |
| 41 | + PathBuf::from(runtime_dir).join("garterm") | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Get the focused PID file path | |
| 45 | + fn focused_file() -> PathBuf { | |
| 46 | + Self::socket_dir().join("focused") | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Get socket path for a specific PID | |
| 50 | + fn socket_for_pid(pid: u32) -> PathBuf { | |
| 51 | + Self::socket_dir().join(format!("garterm-{}.sock", pid)) | |
| 52 | + } | |
| 53 | + | |
| 54 | + pub fn socket_path(&self) -> PathBuf { | |
| 55 | + // Return the focused file path for discovery purposes | |
| 56 | + Self::focused_file() | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// Read the focused PID | |
| 60 | + fn read_focused_pid() -> Option<u32> { | |
| 61 | + std::fs::read_to_string(Self::focused_file()) | |
| 62 | + .ok() | |
| 63 | + .and_then(|s| s.trim().parse().ok()) | |
| 64 | + } | |
| 65 | + | |
| 66 | + pub fn connect(&mut self) -> Result<()> { | |
| 67 | + // Read the focused PID and connect to that instance | |
| 68 | + let pid = Self::read_focused_pid() | |
| 69 | + .ok_or_else(|| anyhow::anyhow!("No focused garterm instance"))?; | |
| 70 | + | |
| 71 | + let socket_path = Self::socket_for_pid(pid); | |
| 72 | + self.client.connect(&socket_path)?; | |
| 73 | + self.focused_pid = Some(pid); | |
| 74 | + | |
| 75 | + Ok(()) | |
| 76 | + } | |
| 77 | + | |
| 78 | + pub fn disconnect(&mut self) { | |
| 79 | + self.client.disconnect(); | |
| 80 | + self.focused_pid = None; | |
| 81 | + } | |
| 82 | + | |
| 83 | + pub fn is_connected(&self) -> bool { | |
| 84 | + self.client.is_connected() | |
| 85 | + } | |
| 86 | + | |
| 87 | + /// List all running garterm instances | |
| 88 | + pub fn list_instances() -> Vec<u32> { | |
| 89 | + let socket_dir = Self::socket_dir(); | |
| 90 | + if !socket_dir.exists() { | |
| 91 | + return Vec::new(); | |
| 92 | + } | |
| 93 | + | |
| 94 | + std::fs::read_dir(socket_dir) | |
| 95 | + .map(|entries| { | |
| 96 | + entries | |
| 97 | + .filter_map(|e| e.ok()) | |
| 98 | + .filter_map(|e| { | |
| 99 | + let name = e.file_name().to_string_lossy().to_string(); | |
| 100 | + if name.starts_with("garterm-") && name.ends_with(".sock") { | |
| 101 | + name.strip_prefix("garterm-") | |
| 102 | + .and_then(|s| s.strip_suffix(".sock")) | |
| 103 | + .and_then(|s| s.parse().ok()) | |
| 104 | + } else { | |
| 105 | + None | |
| 106 | + } | |
| 107 | + }) | |
| 108 | + .collect() | |
| 109 | + }) | |
| 110 | + .unwrap_or_default() | |
| 111 | + } | |
| 112 | + | |
| 113 | + fn send_command(&mut self, command: GartermCommand) -> Result<Option<Value>> { | |
| 114 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 115 | + response.into_result() | |
| 116 | + } | |
| 117 | + | |
| 118 | + pub fn status(&mut self) -> Result<GartermStatus> { | |
| 119 | + let data = self.send_command(GartermCommand::Status)?; | |
| 120 | + if let Some(data) = data { | |
| 121 | + Ok(serde_json::from_value(data)?) | |
| 122 | + } else { | |
| 123 | + Ok(GartermStatus { | |
| 124 | + pid: self.focused_pid, | |
| 125 | + ..Default::default() | |
| 126 | + }) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + pub fn new_window(&mut self) -> Result<()> { | |
| 131 | + self.send_command(GartermCommand::NewWindow)?; | |
| 132 | + Ok(()) | |
| 133 | + } | |
| 134 | + | |
| 135 | + pub fn new_tab(&mut self) -> Result<()> { | |
| 136 | + self.send_command(GartermCommand::NewTab)?; | |
| 137 | + Ok(()) | |
| 138 | + } | |
| 139 | +} | |
| 140 | + | |
| 141 | +impl Default for GartermAdapter { | |
| 142 | + fn default() -> Self { | |
| 143 | + Self::new() | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 148 | +pub struct GartermStatus { | |
| 149 | + #[serde(default)] | |
| 150 | + pub pid: Option<u32>, | |
| 151 | + #[serde(default)] | |
| 152 | + pub title: Option<String>, | |
| 153 | + #[serde(default)] | |
| 154 | + pub tabs: Option<usize>, | |
| 155 | +} | |
gargears/src/ipc/adapters/gartray.rsadded@@ -0,0 +1,107 @@ | ||
| 1 | +//! IPC adapter for gartray (system tray & quick settings) | |
| 2 | + | |
| 3 | +use crate::ipc::client::{IpcClient, StandardResponse}; | |
| 4 | +use crate::ipc::discovery::socket_path_for; | |
| 5 | +use crate::panels::Component; | |
| 6 | +use anyhow::Result; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use serde_json::Value; | |
| 9 | +use std::path::PathBuf; | |
| 10 | + | |
| 11 | +/// Commands for gartray | |
| 12 | +#[derive(Debug, Serialize)] | |
| 13 | +#[serde(tag = "command", rename_all = "snake_case")] | |
| 14 | +enum GartrayCommand { | |
| 15 | + Status, | |
| 16 | + Show, | |
| 17 | + Hide, | |
| 18 | + Toggle, | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Adapter for gartray system tray | |
| 22 | +pub struct GartrayAdapter { | |
| 23 | + client: IpcClient, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl GartrayAdapter { | |
| 27 | + pub fn new() -> Self { | |
| 28 | + use std::time::Duration; | |
| 29 | + Self { | |
| 30 | + client: IpcClient::new().with_timeout(Duration::from_secs(5)), | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub fn socket_path(&self) -> PathBuf { | |
| 35 | + socket_path_for(Component::Gartray) | |
| 36 | + } | |
| 37 | + | |
| 38 | + pub fn connect(&mut self) -> Result<()> { | |
| 39 | + self.client.connect(&self.socket_path()) | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Attempt reconnection with exponential backoff | |
| 43 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 44 | + self.client.reconnect() | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Check if we should attempt reconnection | |
| 48 | + pub fn should_reconnect(&self) -> bool { | |
| 49 | + self.client.should_attempt_reconnect() | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Reset backoff state | |
| 53 | + pub fn reset_backoff(&mut self) { | |
| 54 | + self.client.reset_backoff(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + pub fn disconnect(&mut self) { | |
| 58 | + self.client.disconnect(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + pub fn is_connected(&self) -> bool { | |
| 62 | + self.client.is_connected() | |
| 63 | + } | |
| 64 | + | |
| 65 | + fn send_command(&mut self, command: GartrayCommand) -> Result<Option<Value>> { | |
| 66 | + let response: StandardResponse = self.client.send_receive(&command)?; | |
| 67 | + response.into_result() | |
| 68 | + } | |
| 69 | + | |
| 70 | + pub fn status(&mut self) -> Result<GartrayStatus> { | |
| 71 | + let data = self.send_command(GartrayCommand::Status)?; | |
| 72 | + if let Some(data) = data { | |
| 73 | + Ok(serde_json::from_value(data)?) | |
| 74 | + } else { | |
| 75 | + Ok(GartrayStatus::default()) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + pub fn show(&mut self) -> Result<()> { | |
| 80 | + self.send_command(GartrayCommand::Show)?; | |
| 81 | + Ok(()) | |
| 82 | + } | |
| 83 | + | |
| 84 | + pub fn hide(&mut self) -> Result<()> { | |
| 85 | + self.send_command(GartrayCommand::Hide)?; | |
| 86 | + Ok(()) | |
| 87 | + } | |
| 88 | + | |
| 89 | + pub fn toggle(&mut self) -> Result<()> { | |
| 90 | + self.send_command(GartrayCommand::Toggle)?; | |
| 91 | + Ok(()) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +impl Default for GartrayAdapter { | |
| 96 | + fn default() -> Self { | |
| 97 | + Self::new() | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 102 | +pub struct GartrayStatus { | |
| 103 | + #[serde(default)] | |
| 104 | + pub visible: bool, | |
| 105 | + #[serde(default)] | |
| 106 | + pub tray_icons: usize, | |
| 107 | +} | |
gargears/src/ipc/adapters/mod.rsadded@@ -0,0 +1,25 @@ | ||
| 1 | +//! IPC adapters for gardesk components | |
| 2 | + | |
| 3 | +mod gar; | |
| 4 | +mod garbar; | |
| 5 | +mod garbg; | |
| 6 | +mod garclip; | |
| 7 | +mod garfield; | |
| 8 | +mod garlaunch; | |
| 9 | +mod garlock; | |
| 10 | +mod garnotify; | |
| 11 | +mod garshot; | |
| 12 | +mod garterm; | |
| 13 | +mod gartray; | |
| 14 | + | |
| 15 | +pub use gar::{GarAdapter, GarKeybind, GarRule}; | |
| 16 | +pub use garbar::GarbarAdapter; | |
| 17 | +pub use garbg::{GarbgAdapter, GarbgEvent}; | |
| 18 | +pub use garclip::GarclipAdapter; | |
| 19 | +pub use garfield::GarfieldAdapter; | |
| 20 | +pub use garlaunch::GarlaunchAdapter; | |
| 21 | +pub use garlock::GarlockAdapter; | |
| 22 | +pub use garnotify::GarnotifyAdapter; | |
| 23 | +pub use garshot::GarshotAdapter; | |
| 24 | +pub use garterm::GartermAdapter; | |
| 25 | +pub use gartray::GartrayAdapter; | |
gargears/src/ipc/client.rsadded@@ -0,0 +1,230 @@ | ||
| 1 | +//! Base IPC client functionality | |
| 2 | + | |
| 3 | +use anyhow::{Context, Result}; | |
| 4 | +use std::io::{BufRead, BufReader, Write}; | |
| 5 | +use std::os::unix::net::UnixStream; | |
| 6 | +use std::path::{Path, PathBuf}; | |
| 7 | +use std::thread; | |
| 8 | +use std::time::{Duration, Instant}; | |
| 9 | + | |
| 10 | +/// Default read/write timeout | |
| 11 | +const DEFAULT_TIMEOUT_SECS: u64 = 5; | |
| 12 | + | |
| 13 | +/// Maximum reconnection attempts | |
| 14 | +const MAX_RECONNECT_ATTEMPTS: u32 = 5; | |
| 15 | + | |
| 16 | +/// Initial backoff delay | |
| 17 | +const INITIAL_BACKOFF_MS: u64 = 100; | |
| 18 | + | |
| 19 | +/// Maximum backoff delay | |
| 20 | +const MAX_BACKOFF_MS: u64 = 5000; | |
| 21 | + | |
| 22 | +/// Base IPC client for Unix socket communication | |
| 23 | +pub struct IpcClient { | |
| 24 | + stream: Option<UnixStream>, | |
| 25 | + reader: Option<BufReader<UnixStream>>, | |
| 26 | + socket_path: Option<PathBuf>, | |
| 27 | + timeout: Duration, | |
| 28 | + last_connect_attempt: Option<Instant>, | |
| 29 | + backoff_ms: u64, | |
| 30 | + connect_attempts: u32, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl IpcClient { | |
| 34 | + /// Create a new disconnected client | |
| 35 | + pub fn new() -> Self { | |
| 36 | + Self { | |
| 37 | + stream: None, | |
| 38 | + reader: None, | |
| 39 | + socket_path: None, | |
| 40 | + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), | |
| 41 | + last_connect_attempt: None, | |
| 42 | + backoff_ms: INITIAL_BACKOFF_MS, | |
| 43 | + connect_attempts: 0, | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Set read/write timeout | |
| 48 | + pub fn with_timeout(mut self, timeout: Duration) -> Self { | |
| 49 | + self.timeout = timeout; | |
| 50 | + self | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Connect to a Unix socket | |
| 54 | + pub fn connect(&mut self, path: &Path) -> Result<()> { | |
| 55 | + self.socket_path = Some(path.to_path_buf()); | |
| 56 | + self.connect_internal(path) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// Internal connect implementation | |
| 60 | + fn connect_internal(&mut self, path: &Path) -> Result<()> { | |
| 61 | + let stream = UnixStream::connect(path) | |
| 62 | + .with_context(|| format!("Failed to connect to {:?}", path))?; | |
| 63 | + | |
| 64 | + // Set timeouts | |
| 65 | + stream.set_read_timeout(Some(self.timeout))?; | |
| 66 | + stream.set_write_timeout(Some(self.timeout))?; | |
| 67 | + | |
| 68 | + // Clone for reader | |
| 69 | + let reader_stream = stream.try_clone()?; | |
| 70 | + | |
| 71 | + self.stream = Some(stream); | |
| 72 | + self.reader = Some(BufReader::new(reader_stream)); | |
| 73 | + | |
| 74 | + // Reset backoff on successful connection | |
| 75 | + self.backoff_ms = INITIAL_BACKOFF_MS; | |
| 76 | + self.connect_attempts = 0; | |
| 77 | + self.last_connect_attempt = Some(Instant::now()); | |
| 78 | + | |
| 79 | + Ok(()) | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// Attempt to reconnect with exponential backoff | |
| 83 | + pub fn reconnect(&mut self) -> Result<()> { | |
| 84 | + let path = self.socket_path.clone().context("No socket path stored")?; | |
| 85 | + | |
| 86 | + // Check if we should wait before retrying | |
| 87 | + if let Some(last_attempt) = self.last_connect_attempt { | |
| 88 | + let elapsed = last_attempt.elapsed().as_millis() as u64; | |
| 89 | + if elapsed < self.backoff_ms { | |
| 90 | + let wait_time = self.backoff_ms - elapsed; | |
| 91 | + thread::sleep(Duration::from_millis(wait_time)); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + self.connect_attempts += 1; | |
| 96 | + self.last_connect_attempt = Some(Instant::now()); | |
| 97 | + | |
| 98 | + let result = self.connect_internal(&path); | |
| 99 | + | |
| 100 | + if result.is_err() { | |
| 101 | + // Exponential backoff with cap | |
| 102 | + self.backoff_ms = (self.backoff_ms * 2).min(MAX_BACKOFF_MS); | |
| 103 | + } | |
| 104 | + | |
| 105 | + result | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Attempt to reconnect with retry limit | |
| 109 | + pub fn reconnect_with_retry(&mut self) -> Result<()> { | |
| 110 | + for _ in 0..MAX_RECONNECT_ATTEMPTS { | |
| 111 | + match self.reconnect() { | |
| 112 | + Ok(()) => return Ok(()), | |
| 113 | + Err(_) if self.connect_attempts < MAX_RECONNECT_ATTEMPTS => continue, | |
| 114 | + Err(e) => return Err(e), | |
| 115 | + } | |
| 116 | + } | |
| 117 | + Err(anyhow::anyhow!("Max reconnection attempts exceeded")) | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Check if we should attempt reconnection (backoff elapsed) | |
| 121 | + pub fn should_attempt_reconnect(&self) -> bool { | |
| 122 | + if self.connect_attempts >= MAX_RECONNECT_ATTEMPTS { | |
| 123 | + return false; | |
| 124 | + } | |
| 125 | + match self.last_connect_attempt { | |
| 126 | + Some(last) => last.elapsed().as_millis() as u64 >= self.backoff_ms, | |
| 127 | + None => true, | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + /// Reset reconnection state (call when manual refresh requested) | |
| 132 | + pub fn reset_backoff(&mut self) { | |
| 133 | + self.backoff_ms = INITIAL_BACKOFF_MS; | |
| 134 | + self.connect_attempts = 0; | |
| 135 | + self.last_connect_attempt = None; | |
| 136 | + } | |
| 137 | + | |
| 138 | + /// Disconnect from the socket | |
| 139 | + pub fn disconnect(&mut self) { | |
| 140 | + self.stream = None; | |
| 141 | + self.reader = None; | |
| 142 | + } | |
| 143 | + | |
| 144 | + /// Check if connected | |
| 145 | + pub fn is_connected(&self) -> bool { | |
| 146 | + self.stream.is_some() | |
| 147 | + } | |
| 148 | + | |
| 149 | + /// Send a JSON message and receive a response | |
| 150 | + pub fn send_receive<T: serde::Serialize, R: serde::de::DeserializeOwned>( | |
| 151 | + &mut self, | |
| 152 | + message: &T, | |
| 153 | + ) -> Result<R> { | |
| 154 | + let stream = self | |
| 155 | + .stream | |
| 156 | + .as_mut() | |
| 157 | + .context("Not connected")?; | |
| 158 | + | |
| 159 | + // Serialize and send | |
| 160 | + let json = serde_json::to_string(message)?; | |
| 161 | + writeln!(stream, "{}", json)?; | |
| 162 | + stream.flush()?; | |
| 163 | + | |
| 164 | + // Read response | |
| 165 | + let reader = self.reader.as_mut().context("No reader")?; | |
| 166 | + let mut line = String::new(); | |
| 167 | + reader.read_line(&mut line)?; | |
| 168 | + | |
| 169 | + // Parse response | |
| 170 | + let response: R = serde_json::from_str(&line) | |
| 171 | + .with_context(|| format!("Failed to parse response: {}", line.trim()))?; | |
| 172 | + | |
| 173 | + Ok(response) | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// Send a message without expecting a response | |
| 177 | + pub fn send<T: serde::Serialize>(&mut self, message: &T) -> Result<()> { | |
| 178 | + let stream = self.stream.as_mut().context("Not connected")?; | |
| 179 | + | |
| 180 | + let json = serde_json::to_string(message)?; | |
| 181 | + writeln!(stream, "{}", json)?; | |
| 182 | + stream.flush()?; | |
| 183 | + | |
| 184 | + Ok(()) | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// Try to read a line (non-blocking style with short timeout) | |
| 188 | + pub fn try_read_line(&mut self) -> Option<String> { | |
| 189 | + let reader = self.reader.as_mut()?; | |
| 190 | + | |
| 191 | + // Set a very short timeout for polling | |
| 192 | + if let Some(stream) = &self.stream { | |
| 193 | + let _ = stream.set_read_timeout(Some(Duration::from_millis(10))); | |
| 194 | + } | |
| 195 | + | |
| 196 | + let mut line = String::new(); | |
| 197 | + match reader.read_line(&mut line) { | |
| 198 | + Ok(0) => None, // EOF | |
| 199 | + Ok(_) => Some(line), | |
| 200 | + Err(_) => None, // Timeout or error | |
| 201 | + } | |
| 202 | + } | |
| 203 | +} | |
| 204 | + | |
| 205 | +impl Default for IpcClient { | |
| 206 | + fn default() -> Self { | |
| 207 | + Self::new() | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +/// Standard response format used by most gardesk daemons | |
| 212 | +#[derive(Debug, Clone, serde::Deserialize)] | |
| 213 | +pub struct StandardResponse { | |
| 214 | + pub success: bool, | |
| 215 | + pub data: Option<serde_json::Value>, | |
| 216 | + pub error: Option<String>, | |
| 217 | +} | |
| 218 | + | |
| 219 | +impl StandardResponse { | |
| 220 | + /// Get data or return error | |
| 221 | + pub fn into_result(self) -> Result<Option<serde_json::Value>> { | |
| 222 | + if self.success { | |
| 223 | + Ok(self.data) | |
| 224 | + } else { | |
| 225 | + Err(anyhow::anyhow!( | |
| 226 | + self.error.unwrap_or_else(|| "Unknown error".to_string()) | |
| 227 | + )) | |
| 228 | + } | |
| 229 | + } | |
| 230 | +} | |
gargears/src/ipc/discovery.rsadded@@ -0,0 +1,56 @@ | ||
| 1 | +//! Daemon discovery - check which gardesk daemons are running | |
| 2 | + | |
| 3 | +use crate::panels::Component; | |
| 4 | +use std::path::PathBuf; | |
| 5 | + | |
| 6 | +/// Status of a daemon | |
| 7 | +#[derive(Debug, Clone)] | |
| 8 | +pub struct DaemonStatus { | |
| 9 | + pub component: Component, | |
| 10 | + pub running: bool, | |
| 11 | + pub socket_path: PathBuf, | |
| 12 | +} | |
| 13 | + | |
| 14 | +/// Get the socket path for a component | |
| 15 | +pub fn socket_path_for(component: Component) -> PathBuf { | |
| 16 | + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") | |
| 17 | + .unwrap_or_else(|_| "/tmp".to_string()); | |
| 18 | + | |
| 19 | + let socket_name = match component { | |
| 20 | + Component::Gar => "gar.sock", | |
| 21 | + Component::Garbar => "garbar.sock", | |
| 22 | + Component::Garbg => "garbg.sock", | |
| 23 | + Component::Garterm => "garterm/focused", // Special: points to focused instance | |
| 24 | + Component::Gartray => "gartray.sock", | |
| 25 | + Component::Garshot => "garshot.sock", | |
| 26 | + Component::Garlock => "garlock.sock", | |
| 27 | + Component::Garfield => "garfield.sock", | |
| 28 | + Component::Garclip => "garclip.sock", | |
| 29 | + Component::Garlaunch => "garlaunch.sock", | |
| 30 | + Component::Garnotify => "garnotify.sock", | |
| 31 | + }; | |
| 32 | + | |
| 33 | + PathBuf::from(runtime_dir).join(socket_name) | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Discover which daemons are running | |
| 37 | +pub fn discover_daemons() -> Vec<DaemonStatus> { | |
| 38 | + Component::all() | |
| 39 | + .into_iter() | |
| 40 | + .map(|component| { | |
| 41 | + let socket_path = socket_path_for(component); | |
| 42 | + let running = socket_path.exists(); | |
| 43 | + | |
| 44 | + DaemonStatus { | |
| 45 | + component, | |
| 46 | + running, | |
| 47 | + socket_path, | |
| 48 | + } | |
| 49 | + }) | |
| 50 | + .collect() | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// Check if a specific daemon is running | |
| 54 | +pub fn is_daemon_running(component: Component) -> bool { | |
| 55 | + socket_path_for(component).exists() | |
| 56 | +} | |
gargears/src/ipc/manager.rsadded@@ -0,0 +1,384 @@ | ||
| 1 | +//! Connection manager for coordinating IPC with all gardesk daemons | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::{ | |
| 4 | + GarbgAdapter, GarbgEvent, GarAdapter, GarbarAdapter, GarclipAdapter, GarfieldAdapter, | |
| 5 | + GarlaunchAdapter, GarlockAdapter, GarnotifyAdapter, GarshotAdapter, GartermAdapter, | |
| 6 | + GartrayAdapter, | |
| 7 | +}; | |
| 8 | +use crate::ipc::discovery::{discover_daemons, DaemonStatus}; | |
| 9 | +use crate::panels::Component; | |
| 10 | +use anyhow::Result; | |
| 11 | +use serde_json::Value; | |
| 12 | +use std::collections::HashMap; | |
| 13 | + | |
| 14 | +/// Events from gardesk daemons | |
| 15 | +#[derive(Debug, Clone)] | |
| 16 | +pub enum IpcEvent { | |
| 17 | + /// A daemon connected | |
| 18 | + Connected(Component), | |
| 19 | + /// A daemon disconnected | |
| 20 | + Disconnected(Component), | |
| 21 | + /// Status update from a daemon | |
| 22 | + StatusUpdate { | |
| 23 | + component: Component, | |
| 24 | + data: Value, | |
| 25 | + }, | |
| 26 | + /// Workspace changed (from gar) | |
| 27 | + WorkspaceChanged { | |
| 28 | + old: Option<usize>, | |
| 29 | + new: usize, | |
| 30 | + }, | |
| 31 | + /// Wallpaper changed (from garbg) | |
| 32 | + WallpaperChanged { | |
| 33 | + monitor: String, | |
| 34 | + source: String, | |
| 35 | + }, | |
| 36 | + /// Error from a daemon | |
| 37 | + Error { | |
| 38 | + component: Component, | |
| 39 | + message: String, | |
| 40 | + }, | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Manages connections to all gardesk daemons | |
| 44 | +pub struct ConnectionManager { | |
| 45 | + // Individual adapters | |
| 46 | + pub gar: GarAdapter, | |
| 47 | + pub garbar: GarbarAdapter, | |
| 48 | + pub garbg: GarbgAdapter, | |
| 49 | + pub garterm: GartermAdapter, | |
| 50 | + pub gartray: GartrayAdapter, | |
| 51 | + pub garshot: GarshotAdapter, | |
| 52 | + pub garlock: GarlockAdapter, | |
| 53 | + pub garfield: GarfieldAdapter, | |
| 54 | + pub garclip: GarclipAdapter, | |
| 55 | + pub garlaunch: GarlaunchAdapter, | |
| 56 | + pub garnotify: GarnotifyAdapter, | |
| 57 | + | |
| 58 | + // Connection state | |
| 59 | + connection_state: HashMap<Component, bool>, | |
| 60 | + | |
| 61 | + // Pending events | |
| 62 | + pending_events: Vec<IpcEvent>, | |
| 63 | +} | |
| 64 | + | |
| 65 | +impl ConnectionManager { | |
| 66 | + /// Create a new connection manager | |
| 67 | + pub fn new() -> Self { | |
| 68 | + Self { | |
| 69 | + gar: GarAdapter::new(), | |
| 70 | + garbar: GarbarAdapter::new(), | |
| 71 | + garbg: GarbgAdapter::new(), | |
| 72 | + garterm: GartermAdapter::new(), | |
| 73 | + gartray: GartrayAdapter::new(), | |
| 74 | + garshot: GarshotAdapter::new(), | |
| 75 | + garlock: GarlockAdapter::new(), | |
| 76 | + garfield: GarfieldAdapter::new(), | |
| 77 | + garclip: GarclipAdapter::new(), | |
| 78 | + garlaunch: GarlaunchAdapter::new(), | |
| 79 | + garnotify: GarnotifyAdapter::new(), | |
| 80 | + connection_state: HashMap::new(), | |
| 81 | + pending_events: Vec::new(), | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Discover which daemons are running and attempt to connect | |
| 86 | + pub fn connect_all(&mut self) -> Vec<DaemonStatus> { | |
| 87 | + let statuses = discover_daemons(); | |
| 88 | + | |
| 89 | + for status in &statuses { | |
| 90 | + if status.running { | |
| 91 | + let _ = self.connect(status.component); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + statuses | |
| 96 | + } | |
| 97 | + | |
| 98 | + /// Connect to a specific daemon | |
| 99 | + pub fn connect(&mut self, component: Component) -> Result<()> { | |
| 100 | + let was_connected = self.is_connected(component); | |
| 101 | + | |
| 102 | + let result = match component { | |
| 103 | + Component::Gar => self.gar.connect(), | |
| 104 | + Component::Garbar => self.garbar.connect(), | |
| 105 | + Component::Garbg => self.garbg.connect(), | |
| 106 | + Component::Garterm => self.garterm.connect(), | |
| 107 | + Component::Gartray => self.gartray.connect(), | |
| 108 | + Component::Garshot => self.garshot.connect(), | |
| 109 | + Component::Garlock => self.garlock.connect(), | |
| 110 | + Component::Garfield => self.garfield.connect(), | |
| 111 | + Component::Garclip => self.garclip.connect(), | |
| 112 | + Component::Garlaunch => self.garlaunch.connect(), | |
| 113 | + Component::Garnotify => self.garnotify.connect(), | |
| 114 | + }; | |
| 115 | + | |
| 116 | + match &result { | |
| 117 | + Ok(()) => { | |
| 118 | + self.connection_state.insert(component, true); | |
| 119 | + if !was_connected { | |
| 120 | + self.pending_events.push(IpcEvent::Connected(component)); | |
| 121 | + } | |
| 122 | + } | |
| 123 | + Err(_) => { | |
| 124 | + self.connection_state.insert(component, false); | |
| 125 | + if was_connected { | |
| 126 | + self.pending_events.push(IpcEvent::Disconnected(component)); | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + result | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Disconnect from a specific daemon | |
| 135 | + pub fn disconnect(&mut self, component: Component) { | |
| 136 | + let was_connected = self.is_connected(component); | |
| 137 | + | |
| 138 | + match component { | |
| 139 | + Component::Gar => self.gar.disconnect(), | |
| 140 | + Component::Garbar => self.garbar.disconnect(), | |
| 141 | + Component::Garbg => self.garbg.disconnect(), | |
| 142 | + Component::Garterm => self.garterm.disconnect(), | |
| 143 | + Component::Gartray => self.gartray.disconnect(), | |
| 144 | + Component::Garshot => self.garshot.disconnect(), | |
| 145 | + Component::Garlock => self.garlock.disconnect(), | |
| 146 | + Component::Garfield => self.garfield.disconnect(), | |
| 147 | + Component::Garclip => self.garclip.disconnect(), | |
| 148 | + Component::Garlaunch => self.garlaunch.disconnect(), | |
| 149 | + Component::Garnotify => self.garnotify.disconnect(), | |
| 150 | + } | |
| 151 | + | |
| 152 | + self.connection_state.insert(component, false); | |
| 153 | + if was_connected { | |
| 154 | + self.pending_events.push(IpcEvent::Disconnected(component)); | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// Check if connected to a specific daemon | |
| 159 | + pub fn is_connected(&self, component: Component) -> bool { | |
| 160 | + match component { | |
| 161 | + Component::Gar => self.gar.is_connected(), | |
| 162 | + Component::Garbar => self.garbar.is_connected(), | |
| 163 | + Component::Garbg => self.garbg.is_connected(), | |
| 164 | + Component::Garterm => self.garterm.is_connected(), | |
| 165 | + Component::Gartray => self.gartray.is_connected(), | |
| 166 | + Component::Garshot => self.garshot.is_connected(), | |
| 167 | + Component::Garlock => self.garlock.is_connected(), | |
| 168 | + Component::Garfield => self.garfield.is_connected(), | |
| 169 | + Component::Garclip => self.garclip.is_connected(), | |
| 170 | + Component::Garlaunch => self.garlaunch.is_connected(), | |
| 171 | + Component::Garnotify => self.garnotify.is_connected(), | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + /// Get connection statuses for all components | |
| 176 | + pub fn get_statuses(&self) -> Vec<DaemonStatus> { | |
| 177 | + Component::all() | |
| 178 | + .into_iter() | |
| 179 | + .map(|component| DaemonStatus { | |
| 180 | + component, | |
| 181 | + running: self.is_connected(component), | |
| 182 | + socket_path: crate::ipc::discovery::socket_path_for(component), | |
| 183 | + }) | |
| 184 | + .collect() | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// Poll for events from all connected daemons | |
| 188 | + pub fn poll_events(&mut self) -> Vec<IpcEvent> { | |
| 189 | + let mut events = std::mem::take(&mut self.pending_events); | |
| 190 | + | |
| 191 | + // Poll gar events | |
| 192 | + if self.gar.is_connected() { | |
| 193 | + for event in self.gar.poll_events() { | |
| 194 | + match event.event_type.as_str() { | |
| 195 | + "workspace" => { | |
| 196 | + if let Some(new) = event.data.get("current").and_then(|v| v.as_u64()) { | |
| 197 | + let old = event.data.get("previous").and_then(|v| v.as_u64()); | |
| 198 | + events.push(IpcEvent::WorkspaceChanged { | |
| 199 | + old: old.map(|v| v as usize), | |
| 200 | + new: new as usize, | |
| 201 | + }); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + _ => { | |
| 205 | + events.push(IpcEvent::StatusUpdate { | |
| 206 | + component: Component::Gar, | |
| 207 | + data: event.data, | |
| 208 | + }); | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + // Poll garbg events | |
| 215 | + if self.garbg.is_connected() { | |
| 216 | + for event in self.garbg.poll_events() { | |
| 217 | + match event { | |
| 218 | + GarbgEvent::WallpaperChanged { monitor, source, .. } => { | |
| 219 | + events.push(IpcEvent::WallpaperChanged { monitor, source }); | |
| 220 | + } | |
| 221 | + GarbgEvent::Error { message, .. } => { | |
| 222 | + events.push(IpcEvent::Error { | |
| 223 | + component: Component::Garbg, | |
| 224 | + message, | |
| 225 | + }); | |
| 226 | + } | |
| 227 | + _ => {} | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + | |
| 232 | + events | |
| 233 | + } | |
| 234 | + | |
| 235 | + /// Try to reconnect to daemons that have become available | |
| 236 | + pub fn refresh_connections(&mut self) { | |
| 237 | + let statuses = discover_daemons(); | |
| 238 | + | |
| 239 | + for status in statuses { | |
| 240 | + let currently_connected = self.is_connected(status.component); | |
| 241 | + | |
| 242 | + if status.running && !currently_connected { | |
| 243 | + // Daemon is now available, try to connect | |
| 244 | + let _ = self.connect(status.component); | |
| 245 | + } else if !status.running && currently_connected { | |
| 246 | + // Daemon is no longer available | |
| 247 | + self.disconnect(status.component); | |
| 248 | + } | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + /// Attempt reconnection with exponential backoff for disconnected daemons | |
| 253 | + pub fn reconnect_with_backoff(&mut self, component: Component) -> Result<()> { | |
| 254 | + match component { | |
| 255 | + Component::Gar => self.gar.reconnect(), | |
| 256 | + Component::Garbar => self.garbar.reconnect(), | |
| 257 | + Component::Garbg => self.garbg.reconnect(), | |
| 258 | + Component::Gartray => self.gartray.reconnect(), | |
| 259 | + Component::Garshot => self.garshot.reconnect(), | |
| 260 | + Component::Garlock => self.garlock.reconnect(), | |
| 261 | + Component::Garfield => self.garfield.reconnect(), | |
| 262 | + Component::Garclip => self.garclip.reconnect(), | |
| 263 | + Component::Garlaunch => self.garlaunch.reconnect(), | |
| 264 | + Component::Garnotify => self.garnotify.reconnect(), | |
| 265 | + // garterm uses different socket scheme, just try fresh connect | |
| 266 | + Component::Garterm => self.garterm.connect(), | |
| 267 | + } | |
| 268 | + } | |
| 269 | + | |
| 270 | + /// Check if reconnection should be attempted for a component | |
| 271 | + pub fn should_reconnect(&self, component: Component) -> bool { | |
| 272 | + match component { | |
| 273 | + Component::Gar => self.gar.should_reconnect(), | |
| 274 | + Component::Garbar => self.garbar.should_reconnect(), | |
| 275 | + Component::Garbg => self.garbg.should_reconnect(), | |
| 276 | + Component::Gartray => self.gartray.should_reconnect(), | |
| 277 | + Component::Garshot => self.garshot.should_reconnect(), | |
| 278 | + Component::Garlock => self.garlock.should_reconnect(), | |
| 279 | + Component::Garfield => self.garfield.should_reconnect(), | |
| 280 | + Component::Garclip => self.garclip.should_reconnect(), | |
| 281 | + Component::Garlaunch => self.garlaunch.should_reconnect(), | |
| 282 | + Component::Garnotify => self.garnotify.should_reconnect(), | |
| 283 | + Component::Garterm => true, // Always allow garterm reconnect | |
| 284 | + } | |
| 285 | + } | |
| 286 | + | |
| 287 | + /// Reset backoff state for a component (e.g., after manual refresh) | |
| 288 | + pub fn reset_backoff(&mut self, component: Component) { | |
| 289 | + match component { | |
| 290 | + Component::Gar => self.gar.reset_backoff(), | |
| 291 | + Component::Garbar => self.garbar.reset_backoff(), | |
| 292 | + Component::Garbg => self.garbg.reset_backoff(), | |
| 293 | + Component::Gartray => self.gartray.reset_backoff(), | |
| 294 | + Component::Garshot => self.garshot.reset_backoff(), | |
| 295 | + Component::Garlock => self.garlock.reset_backoff(), | |
| 296 | + Component::Garfield => self.garfield.reset_backoff(), | |
| 297 | + Component::Garclip => self.garclip.reset_backoff(), | |
| 298 | + Component::Garlaunch => self.garlaunch.reset_backoff(), | |
| 299 | + Component::Garnotify => self.garnotify.reset_backoff(), | |
| 300 | + Component::Garterm => {} // garterm doesn't track backoff | |
| 301 | + } | |
| 302 | + } | |
| 303 | + | |
| 304 | + /// Reset backoff for all components | |
| 305 | + pub fn reset_all_backoff(&mut self) { | |
| 306 | + for component in Component::all() { | |
| 307 | + self.reset_backoff(component); | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + /// Subscribe to events from daemons that support it | |
| 312 | + pub fn subscribe_to_events(&mut self) { | |
| 313 | + // Subscribe to gar workspace events | |
| 314 | + if self.gar.is_connected() { | |
| 315 | + let _ = self.gar.subscribe(&["workspace", "monitor", "window"]); | |
| 316 | + } | |
| 317 | + | |
| 318 | + // Subscribe to garbg events | |
| 319 | + if self.garbg.is_connected() { | |
| 320 | + let _ = self.garbg.subscribe(&["wallpaper_changed", "slideshow_advanced"]); | |
| 321 | + } | |
| 322 | + } | |
| 323 | + | |
| 324 | + /// Take the gar adapter, replacing it with a disconnected one | |
| 325 | + pub fn take_gar_adapter(&mut self) -> GarAdapter { | |
| 326 | + std::mem::take(&mut self.gar) | |
| 327 | + } | |
| 328 | + | |
| 329 | + /// Take the garbar adapter, replacing it with a disconnected one | |
| 330 | + pub fn take_garbar_adapter(&mut self) -> GarbarAdapter { | |
| 331 | + std::mem::take(&mut self.garbar) | |
| 332 | + } | |
| 333 | + | |
| 334 | + /// Take the garbg adapter, replacing it with a disconnected one | |
| 335 | + pub fn take_garbg_adapter(&mut self) -> GarbgAdapter { | |
| 336 | + std::mem::take(&mut self.garbg) | |
| 337 | + } | |
| 338 | + | |
| 339 | + /// Take the garterm adapter, replacing it with a disconnected one | |
| 340 | + pub fn take_garterm_adapter(&mut self) -> GartermAdapter { | |
| 341 | + std::mem::take(&mut self.garterm) | |
| 342 | + } | |
| 343 | + | |
| 344 | + /// Take the gartray adapter, replacing it with a disconnected one | |
| 345 | + pub fn take_gartray_adapter(&mut self) -> GartrayAdapter { | |
| 346 | + std::mem::take(&mut self.gartray) | |
| 347 | + } | |
| 348 | + | |
| 349 | + /// Take the garshot adapter, replacing it with a disconnected one | |
| 350 | + pub fn take_garshot_adapter(&mut self) -> GarshotAdapter { | |
| 351 | + std::mem::take(&mut self.garshot) | |
| 352 | + } | |
| 353 | + | |
| 354 | + /// Take the garlock adapter, replacing it with a disconnected one | |
| 355 | + pub fn take_garlock_adapter(&mut self) -> GarlockAdapter { | |
| 356 | + std::mem::take(&mut self.garlock) | |
| 357 | + } | |
| 358 | + | |
| 359 | + /// Take the garfield adapter, replacing it with a disconnected one | |
| 360 | + pub fn take_garfield_adapter(&mut self) -> GarfieldAdapter { | |
| 361 | + std::mem::take(&mut self.garfield) | |
| 362 | + } | |
| 363 | + | |
| 364 | + /// Take the garclip adapter, replacing it with a disconnected one | |
| 365 | + pub fn take_garclip_adapter(&mut self) -> GarclipAdapter { | |
| 366 | + std::mem::take(&mut self.garclip) | |
| 367 | + } | |
| 368 | + | |
| 369 | + /// Take the garlaunch adapter, replacing it with a disconnected one | |
| 370 | + pub fn take_garlaunch_adapter(&mut self) -> GarlaunchAdapter { | |
| 371 | + std::mem::take(&mut self.garlaunch) | |
| 372 | + } | |
| 373 | + | |
| 374 | + /// Take the garnotify adapter, replacing it with a disconnected one | |
| 375 | + pub fn take_garnotify_adapter(&mut self) -> GarnotifyAdapter { | |
| 376 | + std::mem::take(&mut self.garnotify) | |
| 377 | + } | |
| 378 | +} | |
| 379 | + | |
| 380 | +impl Default for ConnectionManager { | |
| 381 | + fn default() -> Self { | |
| 382 | + Self::new() | |
| 383 | + } | |
| 384 | +} | |
gargears/src/ipc/mod.rsadded@@ -0,0 +1,10 @@ | ||
| 1 | +//! IPC communication with gardesk daemons | |
| 2 | + | |
| 3 | +pub mod adapter; | |
| 4 | +pub mod adapters; | |
| 5 | +pub mod client; | |
| 6 | +pub mod discovery; | |
| 7 | +pub mod manager; | |
| 8 | + | |
| 9 | +pub use adapter::IpcAdapter; | |
| 10 | +pub use manager::{ConnectionManager, IpcEvent}; | |
gargears/src/main.rsadded@@ -0,0 +1,91 @@ | ||
| 1 | +mod app; | |
| 2 | +mod config; | |
| 3 | +mod daemon; | |
| 4 | +mod ipc; | |
| 5 | +mod panels; | |
| 6 | +mod ui; | |
| 7 | + | |
| 8 | +use anyhow::Result; | |
| 9 | +use clap::Parser; | |
| 10 | +use std::thread; | |
| 11 | +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; | |
| 12 | + | |
| 13 | +#[derive(Parser, Debug)] | |
| 14 | +#[command(name = "gargears")] | |
| 15 | +#[command(about = "Centralized GUI for gardesk configuration")] | |
| 16 | +struct Args { | |
| 17 | + /// Run as daemon (persistent, with tray integration) | |
| 18 | + #[arg(short, long)] | |
| 19 | + daemon: bool, | |
| 20 | + | |
| 21 | + /// Don't fork to background (daemon mode only) | |
| 22 | + #[arg(long)] | |
| 23 | + no_fork: bool, | |
| 24 | + | |
| 25 | + /// Initial panel to show | |
| 26 | + #[arg(short, long)] | |
| 27 | + panel: Option<String>, | |
| 28 | +} | |
| 29 | + | |
| 30 | +fn main() -> Result<()> { | |
| 31 | + // Initialize logging | |
| 32 | + tracing_subscriber::registry() | |
| 33 | + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) | |
| 34 | + .with(tracing_subscriber::fmt::layer()) | |
| 35 | + .init(); | |
| 36 | + | |
| 37 | + let args = Args::parse(); | |
| 38 | + | |
| 39 | + tracing::info!("Starting gargears"); | |
| 40 | + | |
| 41 | + if args.daemon { | |
| 42 | + run_daemon(&args) | |
| 43 | + } else { | |
| 44 | + run_gui(&args) | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +fn run_daemon(args: &Args) -> Result<()> { | |
| 49 | + // Check if another daemon is already running | |
| 50 | + let socket_path = gargears_ipc::socket_path(); | |
| 51 | + if socket_path.exists() { | |
| 52 | + // Try to connect to see if it's actually running | |
| 53 | + if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() { | |
| 54 | + tracing::error!("Another gargears daemon is already running"); | |
| 55 | + return Err(anyhow::anyhow!("Daemon already running")); | |
| 56 | + } | |
| 57 | + // Stale socket, remove it | |
| 58 | + let _ = std::fs::remove_file(&socket_path); | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Create command channel | |
| 62 | + let (command_tx, command_rx) = daemon::create_command_channel(); | |
| 63 | + | |
| 64 | + // Create app in daemon mode (starts hidden) | |
| 65 | + let initial_panel = args.panel.as_deref(); | |
| 66 | + let mut app = app::App::new_daemon(initial_panel)?; | |
| 67 | + | |
| 68 | + // Get visibility state for IPC server | |
| 69 | + let visible_state = app.visible_state(); | |
| 70 | + | |
| 71 | + // Start IPC server in background thread | |
| 72 | + let ipc_server = daemon::IpcServer::new(command_tx)?; | |
| 73 | + thread::spawn(move || { | |
| 74 | + ipc_server.run(visible_state); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + tracing::info!("gargears daemon started"); | |
| 78 | + tracing::info!("Use 'gargearsctl show' or 'gargearsctl toggle' to show the window"); | |
| 79 | + | |
| 80 | + // Send startup notification | |
| 81 | + daemon::notify_startup(); | |
| 82 | + | |
| 83 | + // Run app event loop | |
| 84 | + app.run_daemon(command_rx) | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn run_gui(args: &Args) -> Result<()> { | |
| 88 | + let initial_panel = args.panel.as_deref(); | |
| 89 | + let mut app = app::App::new(initial_panel)?; | |
| 90 | + app.run() | |
| 91 | +} | |
gargears/src/panels/gar.rsadded@@ -0,0 +1,759 @@ | ||
| 1 | +//! Configuration panel for gar (window manager) | |
| 2 | + | |
| 3 | +use crate::config::{GarSettings, LuaConfigWriter, LuaValue}; | |
| 4 | +use crate::ipc::adapters::{GarAdapter, GarKeybind, GarRule}; | |
| 5 | +use crate::ui::widgets::{Button, ColorPicker, List, NumberInput, Section, Toggle, WidgetEvent}; | |
| 6 | +use crate::ui::{Panel, PanelAction}; | |
| 7 | +use anyhow::Result; | |
| 8 | +use gartk_core::{InputEvent, Key, Rect, Theme}; | |
| 9 | +use gartk_render::{Renderer, TextStyle}; | |
| 10 | + | |
| 11 | +/// Gar configuration panel | |
| 12 | +pub struct GarPanel { | |
| 13 | + adapter: GarAdapter, | |
| 14 | + | |
| 15 | + // Sections | |
| 16 | + borders_section: Section, | |
| 17 | + gaps_section: Section, | |
| 18 | + titlebar_section: Section, | |
| 19 | + behavior_section: Section, | |
| 20 | + | |
| 21 | + // Border settings | |
| 22 | + border_width: NumberInput, | |
| 23 | + border_color_focused: ColorPicker, | |
| 24 | + border_color_unfocused: ColorPicker, | |
| 25 | + | |
| 26 | + // Gap settings | |
| 27 | + gap_inner: NumberInput, | |
| 28 | + gap_outer: NumberInput, | |
| 29 | + | |
| 30 | + // Titlebar settings | |
| 31 | + titlebar_enabled: Toggle, | |
| 32 | + titlebar_height: NumberInput, | |
| 33 | + | |
| 34 | + // Behavior settings | |
| 35 | + focus_follows_mouse: Toggle, | |
| 36 | + mouse_follows_focus: Toggle, | |
| 37 | + | |
| 38 | + // Keybinds section (read-only) | |
| 39 | + keybinds_section: Section, | |
| 40 | + keybinds_list: List, | |
| 41 | + | |
| 42 | + // Window rules section (read-only) | |
| 43 | + rules_section: Section, | |
| 44 | + rules_list: List, | |
| 45 | + | |
| 46 | + // Action buttons | |
| 47 | + apply_button: Button, | |
| 48 | + reset_button: Button, | |
| 49 | + save_button: Button, | |
| 50 | + | |
| 51 | + // State | |
| 52 | + original_values: GarValues, | |
| 53 | + dirty: bool, | |
| 54 | + scroll_offset: i32, | |
| 55 | + instant_apply: bool, | |
| 56 | + pending_change: Option<&'static str>, | |
| 57 | +} | |
| 58 | + | |
| 59 | +#[derive(Clone, Default)] | |
| 60 | +struct GarValues { | |
| 61 | + border_width: i32, | |
| 62 | + border_color_focused: String, | |
| 63 | + border_color_unfocused: String, | |
| 64 | + gap_inner: i32, | |
| 65 | + gap_outer: i32, | |
| 66 | + titlebar_enabled: bool, | |
| 67 | + titlebar_height: i32, | |
| 68 | + focus_follows_mouse: bool, | |
| 69 | + mouse_follows_focus: bool, | |
| 70 | +} | |
| 71 | + | |
| 72 | +impl GarPanel { | |
| 73 | + pub fn new(mut adapter: GarAdapter) -> Self { | |
| 74 | + // Try to get current values | |
| 75 | + let values = if adapter.is_connected() { | |
| 76 | + Self::fetch_values(&mut adapter) | |
| 77 | + } else { | |
| 78 | + GarValues::default() | |
| 79 | + }; | |
| 80 | + | |
| 81 | + // Fetch keybinds and rules | |
| 82 | + let keybinds = if adapter.is_connected() { | |
| 83 | + adapter.query_keybinds().unwrap_or_default() | |
| 84 | + } else { | |
| 85 | + vec![] | |
| 86 | + }; | |
| 87 | + let rules = if adapter.is_connected() { | |
| 88 | + adapter.query_rules().unwrap_or_default() | |
| 89 | + } else { | |
| 90 | + vec![] | |
| 91 | + }; | |
| 92 | + | |
| 93 | + let keybind_items: Vec<String> = keybinds.iter().map(|k| k.display()).collect(); | |
| 94 | + let rule_items: Vec<String> = rules.iter().map(|r| r.display()).collect(); | |
| 95 | + | |
| 96 | + Self { | |
| 97 | + adapter, | |
| 98 | + borders_section: Section::new("Borders"), | |
| 99 | + gaps_section: Section::new("Gaps"), | |
| 100 | + titlebar_section: Section::new("Titlebar"), | |
| 101 | + behavior_section: Section::new("Behavior"), | |
| 102 | + border_width: NumberInput::new("Border Width", values.border_width) | |
| 103 | + .with_range(0, 10) | |
| 104 | + .with_step(1), | |
| 105 | + border_color_focused: ColorPicker::new("Focused Color", &values.border_color_focused), | |
| 106 | + border_color_unfocused: ColorPicker::new("Unfocused Color", &values.border_color_unfocused), | |
| 107 | + gap_inner: NumberInput::new("Inner Gap", values.gap_inner) | |
| 108 | + .with_range(0, 50) | |
| 109 | + .with_step(2), | |
| 110 | + gap_outer: NumberInput::new("Outer Gap", values.gap_outer) | |
| 111 | + .with_range(0, 50) | |
| 112 | + .with_step(2), | |
| 113 | + titlebar_enabled: Toggle::new("Enabled", values.titlebar_enabled), | |
| 114 | + titlebar_height: NumberInput::new("Height", values.titlebar_height) | |
| 115 | + .with_range(0, 48) | |
| 116 | + .with_step(2), | |
| 117 | + focus_follows_mouse: Toggle::new("Focus Follows Mouse", values.focus_follows_mouse), | |
| 118 | + mouse_follows_focus: Toggle::new("Mouse Follows Focus", values.mouse_follows_focus), | |
| 119 | + keybinds_section: Section::new("Keybinds (read-only)"), | |
| 120 | + keybinds_list: List::new().with_items(keybind_items).with_max_visible(4), | |
| 121 | + rules_section: Section::new("Window Rules (read-only)"), | |
| 122 | + rules_list: List::new().with_items(rule_items).with_max_visible(4), | |
| 123 | + apply_button: Button::new("Apply").primary(), | |
| 124 | + reset_button: Button::new("Reset"), | |
| 125 | + save_button: Button::new("Save"), | |
| 126 | + original_values: values, | |
| 127 | + dirty: false, | |
| 128 | + scroll_offset: 0, | |
| 129 | + instant_apply: true, | |
| 130 | + pending_change: None, | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + fn fetch_values(adapter: &mut GarAdapter) -> GarValues { | |
| 135 | + // Try to query config from gar | |
| 136 | + if let Ok(config) = adapter.query_config() { | |
| 137 | + GarValues { | |
| 138 | + border_width: config.border_width.unwrap_or(2) as i32, | |
| 139 | + border_color_focused: config.border_color_focused.unwrap_or_else(|| "#5294e2".into()), | |
| 140 | + border_color_unfocused: config | |
| 141 | + .border_color_unfocused | |
| 142 | + .unwrap_or_else(|| "#3c3c3c".into()), | |
| 143 | + gap_inner: config.gap_inner.unwrap_or(8) as i32, | |
| 144 | + gap_outer: config.gap_outer.unwrap_or(8) as i32, | |
| 145 | + titlebar_enabled: config.titlebar_enabled.unwrap_or(false), | |
| 146 | + titlebar_height: config.titlebar_height.unwrap_or(24) as i32, | |
| 147 | + focus_follows_mouse: config.focus_follows_mouse.unwrap_or(false), | |
| 148 | + mouse_follows_focus: config.mouse_follows_focus.unwrap_or(false), | |
| 149 | + } | |
| 150 | + } else { | |
| 151 | + GarValues { | |
| 152 | + border_width: 2, | |
| 153 | + border_color_focused: "#5294e2".into(), | |
| 154 | + border_color_unfocused: "#3c3c3c".into(), | |
| 155 | + gap_inner: 8, | |
| 156 | + gap_outer: 8, | |
| 157 | + titlebar_enabled: false, | |
| 158 | + titlebar_height: 24, | |
| 159 | + focus_follows_mouse: false, | |
| 160 | + mouse_follows_focus: false, | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + fn check_dirty(&mut self) { | |
| 166 | + self.dirty = self.border_width.value != self.original_values.border_width | |
| 167 | + || self.border_color_focused.value != self.original_values.border_color_focused | |
| 168 | + || self.border_color_unfocused.value != self.original_values.border_color_unfocused | |
| 169 | + || self.gap_inner.value != self.original_values.gap_inner | |
| 170 | + || self.gap_outer.value != self.original_values.gap_outer | |
| 171 | + || self.titlebar_enabled.value != self.original_values.titlebar_enabled | |
| 172 | + || self.titlebar_height.value != self.original_values.titlebar_height | |
| 173 | + || self.focus_follows_mouse.value != self.original_values.focus_follows_mouse | |
| 174 | + || self.mouse_follows_focus.value != self.original_values.mouse_follows_focus; | |
| 175 | + } | |
| 176 | + | |
| 177 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 178 | + let padding = theme.padding as i32; | |
| 179 | + let row_height = 36; | |
| 180 | + let section_height = 32; | |
| 181 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 182 | + | |
| 183 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 184 | + | |
| 185 | + // Borders section | |
| 186 | + self.borders_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 187 | + y += section_height; | |
| 188 | + | |
| 189 | + if self.borders_section.expanded { | |
| 190 | + self.border_width.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 191 | + y += row_height; | |
| 192 | + | |
| 193 | + self.border_color_focused.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 194 | + y += row_height; | |
| 195 | + | |
| 196 | + self.border_color_unfocused.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 197 | + y += row_height; | |
| 198 | + } | |
| 199 | + | |
| 200 | + y += padding / 2; | |
| 201 | + | |
| 202 | + // Gaps section | |
| 203 | + self.gaps_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 204 | + y += section_height; | |
| 205 | + | |
| 206 | + if self.gaps_section.expanded { | |
| 207 | + self.gap_inner.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 208 | + y += row_height; | |
| 209 | + | |
| 210 | + self.gap_outer.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 211 | + y += row_height; | |
| 212 | + } | |
| 213 | + | |
| 214 | + y += padding / 2; | |
| 215 | + | |
| 216 | + // Titlebar section | |
| 217 | + self.titlebar_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 218 | + y += section_height; | |
| 219 | + | |
| 220 | + if self.titlebar_section.expanded { | |
| 221 | + self.titlebar_enabled.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 222 | + y += row_height; | |
| 223 | + | |
| 224 | + self.titlebar_height.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 225 | + y += row_height; | |
| 226 | + } | |
| 227 | + | |
| 228 | + y += padding / 2; | |
| 229 | + | |
| 230 | + // Behavior section | |
| 231 | + self.behavior_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 232 | + y += section_height; | |
| 233 | + | |
| 234 | + if self.behavior_section.expanded { | |
| 235 | + self.focus_follows_mouse.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 236 | + y += row_height; | |
| 237 | + | |
| 238 | + self.mouse_follows_focus.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 239 | + y += row_height; | |
| 240 | + } | |
| 241 | + | |
| 242 | + y += padding / 2; | |
| 243 | + | |
| 244 | + // Keybinds section (read-only) | |
| 245 | + self.keybinds_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 246 | + y += section_height; | |
| 247 | + | |
| 248 | + if self.keybinds_section.expanded { | |
| 249 | + let list_height = (row_height * 4).min(120) as u32; | |
| 250 | + self.keybinds_list.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, list_height); | |
| 251 | + y += list_height as i32 + 4; | |
| 252 | + } | |
| 253 | + | |
| 254 | + y += padding / 2; | |
| 255 | + | |
| 256 | + // Rules section (read-only) | |
| 257 | + self.rules_section.bounds = Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 258 | + y += section_height; | |
| 259 | + | |
| 260 | + if self.rules_section.expanded { | |
| 261 | + let list_height = (row_height * 4).min(120) as u32; | |
| 262 | + self.rules_list.bounds = Rect::new(bounds.x + padding + 16, y, widget_width - 16, list_height); | |
| 263 | + } | |
| 264 | + | |
| 265 | + // Action buttons at bottom | |
| 266 | + let button_y = bounds.y + bounds.height as i32 - padding - 36; | |
| 267 | + let button_width = 80; | |
| 268 | + | |
| 269 | + self.reset_button.bounds = Rect::new( | |
| 270 | + bounds.x + bounds.width as i32 - padding - button_width * 3 - 16, | |
| 271 | + button_y, | |
| 272 | + button_width as u32, | |
| 273 | + 32, | |
| 274 | + ); | |
| 275 | + | |
| 276 | + self.apply_button.bounds = Rect::new( | |
| 277 | + bounds.x + bounds.width as i32 - padding - button_width * 2 - 8, | |
| 278 | + button_y, | |
| 279 | + button_width as u32, | |
| 280 | + 32, | |
| 281 | + ); | |
| 282 | + | |
| 283 | + self.save_button.bounds = Rect::new( | |
| 284 | + bounds.x + bounds.width as i32 - padding - button_width, | |
| 285 | + button_y, | |
| 286 | + button_width as u32, | |
| 287 | + 32, | |
| 288 | + ); | |
| 289 | + } | |
| 290 | +} | |
| 291 | + | |
| 292 | +impl Panel for GarPanel { | |
| 293 | + fn name(&self) -> &str { | |
| 294 | + "gar" | |
| 295 | + } | |
| 296 | + | |
| 297 | + fn description(&self) -> &str { | |
| 298 | + "Tiling window manager" | |
| 299 | + } | |
| 300 | + | |
| 301 | + fn is_dirty(&self) -> bool { | |
| 302 | + self.dirty | |
| 303 | + } | |
| 304 | + | |
| 305 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 306 | + self.layout_widgets(bounds, theme); | |
| 307 | + | |
| 308 | + // Render sections and widgets | |
| 309 | + self.borders_section.render(renderer, theme)?; | |
| 310 | + if self.borders_section.expanded { | |
| 311 | + self.border_width.render(renderer, theme)?; | |
| 312 | + self.border_color_focused.render(renderer, theme)?; | |
| 313 | + self.border_color_unfocused.render(renderer, theme)?; | |
| 314 | + } | |
| 315 | + | |
| 316 | + self.gaps_section.render(renderer, theme)?; | |
| 317 | + if self.gaps_section.expanded { | |
| 318 | + self.gap_inner.render(renderer, theme)?; | |
| 319 | + self.gap_outer.render(renderer, theme)?; | |
| 320 | + } | |
| 321 | + | |
| 322 | + self.titlebar_section.render(renderer, theme)?; | |
| 323 | + if self.titlebar_section.expanded { | |
| 324 | + self.titlebar_enabled.render(renderer, theme)?; | |
| 325 | + self.titlebar_height.render(renderer, theme)?; | |
| 326 | + } | |
| 327 | + | |
| 328 | + self.behavior_section.render(renderer, theme)?; | |
| 329 | + if self.behavior_section.expanded { | |
| 330 | + self.focus_follows_mouse.render(renderer, theme)?; | |
| 331 | + self.mouse_follows_focus.render(renderer, theme)?; | |
| 332 | + } | |
| 333 | + | |
| 334 | + self.keybinds_section.render(renderer, theme)?; | |
| 335 | + if self.keybinds_section.expanded { | |
| 336 | + self.keybinds_list.render(renderer, theme)?; | |
| 337 | + } | |
| 338 | + | |
| 339 | + self.rules_section.render(renderer, theme)?; | |
| 340 | + if self.rules_section.expanded { | |
| 341 | + self.rules_list.render(renderer, theme)?; | |
| 342 | + } | |
| 343 | + | |
| 344 | + // Render action buttons | |
| 345 | + self.reset_button.render(renderer, theme)?; | |
| 346 | + self.apply_button.render(renderer, theme)?; | |
| 347 | + self.save_button.render(renderer, theme)?; | |
| 348 | + | |
| 349 | + // Dirty indicator | |
| 350 | + if self.dirty { | |
| 351 | + let indicator_style = TextStyle::new() | |
| 352 | + .font_family(&theme.font_family) | |
| 353 | + .font_size(theme.font_size * 0.75) | |
| 354 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 355 | + | |
| 356 | + renderer.text( | |
| 357 | + "● Unsaved changes", | |
| 358 | + (bounds.x + theme.padding as i32) as f64, | |
| 359 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 28) as f64, | |
| 360 | + &indicator_style, | |
| 361 | + )?; | |
| 362 | + } | |
| 363 | + | |
| 364 | + Ok(()) | |
| 365 | + } | |
| 366 | + | |
| 367 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 368 | + match event { | |
| 369 | + InputEvent::Key(ke) if ke.pressed => { | |
| 370 | + // Handle text input keys - with instant apply support | |
| 371 | + if let WidgetEvent::Changed = self.border_color_focused.on_key(&ke.key) { | |
| 372 | + self.check_dirty(); | |
| 373 | + self.pending_change = Some("border_color_focused"); | |
| 374 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 375 | + } | |
| 376 | + if let WidgetEvent::Changed = self.border_color_unfocused.on_key(&ke.key) { | |
| 377 | + self.check_dirty(); | |
| 378 | + self.pending_change = Some("border_color_unfocused"); | |
| 379 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 380 | + } | |
| 381 | + | |
| 382 | + // Scroll with Page Up/Down | |
| 383 | + match &ke.key { | |
| 384 | + Key::PageUp => { | |
| 385 | + self.scroll_offset = (self.scroll_offset - 100).max(0); | |
| 386 | + return PanelAction::Redraw; | |
| 387 | + } | |
| 388 | + Key::PageDown => { | |
| 389 | + self.scroll_offset += 100; | |
| 390 | + return PanelAction::Redraw; | |
| 391 | + } | |
| 392 | + _ => {} | |
| 393 | + } | |
| 394 | + | |
| 395 | + PanelAction::None | |
| 396 | + } | |
| 397 | + InputEvent::MousePress(me) => { | |
| 398 | + let x = me.position.x; | |
| 399 | + let y = me.position.y; | |
| 400 | + | |
| 401 | + // Check sections | |
| 402 | + if let WidgetEvent::Changed = self.borders_section.on_click(x, y) { | |
| 403 | + return PanelAction::Redraw; | |
| 404 | + } | |
| 405 | + if let WidgetEvent::Changed = self.gaps_section.on_click(x, y) { | |
| 406 | + return PanelAction::Redraw; | |
| 407 | + } | |
| 408 | + if let WidgetEvent::Changed = self.titlebar_section.on_click(x, y) { | |
| 409 | + return PanelAction::Redraw; | |
| 410 | + } | |
| 411 | + if let WidgetEvent::Changed = self.behavior_section.on_click(x, y) { | |
| 412 | + return PanelAction::Redraw; | |
| 413 | + } | |
| 414 | + if let WidgetEvent::Changed = self.keybinds_section.on_click(x, y) { | |
| 415 | + return PanelAction::Redraw; | |
| 416 | + } | |
| 417 | + if let WidgetEvent::Changed = self.rules_section.on_click(x, y) { | |
| 418 | + return PanelAction::Redraw; | |
| 419 | + } | |
| 420 | + | |
| 421 | + // Check widgets - with instant apply support | |
| 422 | + if let WidgetEvent::Changed = self.border_width.on_click(x, y) { | |
| 423 | + self.check_dirty(); | |
| 424 | + self.pending_change = Some("border_width"); | |
| 425 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 426 | + } | |
| 427 | + if let WidgetEvent::Changed = self.gap_inner.on_click(x, y) { | |
| 428 | + self.check_dirty(); | |
| 429 | + self.pending_change = Some("gap_inner"); | |
| 430 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 431 | + } | |
| 432 | + if let WidgetEvent::Changed = self.gap_outer.on_click(x, y) { | |
| 433 | + self.check_dirty(); | |
| 434 | + self.pending_change = Some("gap_outer"); | |
| 435 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 436 | + } | |
| 437 | + if let WidgetEvent::Changed = self.titlebar_enabled.on_click(x, y) { | |
| 438 | + self.check_dirty(); | |
| 439 | + self.pending_change = Some("titlebar_enabled"); | |
| 440 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 441 | + } | |
| 442 | + if let WidgetEvent::Changed = self.titlebar_height.on_click(x, y) { | |
| 443 | + self.check_dirty(); | |
| 444 | + self.pending_change = Some("titlebar_height"); | |
| 445 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 446 | + } | |
| 447 | + if let WidgetEvent::Changed = self.focus_follows_mouse.on_click(x, y) { | |
| 448 | + self.check_dirty(); | |
| 449 | + self.pending_change = Some("focus_follows_mouse"); | |
| 450 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 451 | + } | |
| 452 | + if let WidgetEvent::Changed = self.mouse_follows_focus.on_click(x, y) { | |
| 453 | + self.check_dirty(); | |
| 454 | + self.pending_change = Some("mouse_follows_focus"); | |
| 455 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 456 | + } | |
| 457 | + | |
| 458 | + // Color inputs - clear other focus first, then check clicks | |
| 459 | + // This ensures only one color picker is focused at a time | |
| 460 | + let input1_bounds = self.border_color_focused.bounds; | |
| 461 | + let input2_bounds = self.border_color_unfocused.bounds; | |
| 462 | + let in_input1 = x >= input1_bounds.x && x < input1_bounds.x + input1_bounds.width as i32 | |
| 463 | + && y >= input1_bounds.y && y < input1_bounds.y + input1_bounds.height as i32; | |
| 464 | + let in_input2 = x >= input2_bounds.x && x < input2_bounds.x + input2_bounds.width as i32 | |
| 465 | + && y >= input2_bounds.y && y < input2_bounds.y + input2_bounds.height as i32; | |
| 466 | + | |
| 467 | + // Clear focus from the other picker when clicking one | |
| 468 | + if in_input1 { | |
| 469 | + self.border_color_unfocused.blur(); | |
| 470 | + } else if in_input2 { | |
| 471 | + self.border_color_focused.blur(); | |
| 472 | + } else { | |
| 473 | + // Clicking elsewhere - blur both | |
| 474 | + self.border_color_focused.blur(); | |
| 475 | + self.border_color_unfocused.blur(); | |
| 476 | + } | |
| 477 | + | |
| 478 | + let focused1 = self.border_color_focused.on_click(x, y); | |
| 479 | + let focused2 = self.border_color_unfocused.on_click(x, y); | |
| 480 | + | |
| 481 | + // Action buttons | |
| 482 | + if let WidgetEvent::Clicked = self.apply_button.on_click(x, y) { | |
| 483 | + return PanelAction::Apply; | |
| 484 | + } | |
| 485 | + if let WidgetEvent::Clicked = self.reset_button.on_click(x, y) { | |
| 486 | + return PanelAction::Reset; | |
| 487 | + } | |
| 488 | + if let WidgetEvent::Clicked = self.save_button.on_click(x, y) { | |
| 489 | + return PanelAction::Save; | |
| 490 | + } | |
| 491 | + | |
| 492 | + // Redraw if focus state changed (Focus or Blur events) | |
| 493 | + if matches!(focused1, WidgetEvent::Focus | WidgetEvent::Blur) | |
| 494 | + || matches!(focused2, WidgetEvent::Focus | WidgetEvent::Blur) | |
| 495 | + { | |
| 496 | + return PanelAction::Redraw; | |
| 497 | + } | |
| 498 | + | |
| 499 | + PanelAction::None | |
| 500 | + } | |
| 501 | + InputEvent::Scroll(se) => { | |
| 502 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 503 | + PanelAction::Redraw | |
| 504 | + } | |
| 505 | + _ => PanelAction::None, | |
| 506 | + } | |
| 507 | + } | |
| 508 | + | |
| 509 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 510 | + let mut changed = false; | |
| 511 | + | |
| 512 | + changed |= self.borders_section.on_mouse_move(x, y); | |
| 513 | + changed |= self.gaps_section.on_mouse_move(x, y); | |
| 514 | + changed |= self.titlebar_section.on_mouse_move(x, y); | |
| 515 | + changed |= self.behavior_section.on_mouse_move(x, y); | |
| 516 | + changed |= self.keybinds_section.on_mouse_move(x, y); | |
| 517 | + changed |= self.rules_section.on_mouse_move(x, y); | |
| 518 | + | |
| 519 | + changed |= self.border_width.on_mouse_move(x, y); | |
| 520 | + changed |= self.border_color_focused.on_mouse_move(x, y); | |
| 521 | + changed |= self.border_color_unfocused.on_mouse_move(x, y); | |
| 522 | + changed |= self.gap_inner.on_mouse_move(x, y); | |
| 523 | + changed |= self.gap_outer.on_mouse_move(x, y); | |
| 524 | + changed |= self.titlebar_enabled.on_mouse_move(x, y); | |
| 525 | + changed |= self.titlebar_height.on_mouse_move(x, y); | |
| 526 | + changed |= self.focus_follows_mouse.on_mouse_move(x, y); | |
| 527 | + changed |= self.mouse_follows_focus.on_mouse_move(x, y); | |
| 528 | + | |
| 529 | + changed |= self.apply_button.on_mouse_move(x, y); | |
| 530 | + changed |= self.reset_button.on_mouse_move(x, y); | |
| 531 | + changed |= self.save_button.on_mouse_move(x, y); | |
| 532 | + | |
| 533 | + changed | |
| 534 | + } | |
| 535 | + | |
| 536 | + fn blur_focused(&mut self) { | |
| 537 | + self.border_color_focused.blur(); | |
| 538 | + self.border_color_unfocused.blur(); | |
| 539 | + } | |
| 540 | + | |
| 541 | + fn reset(&mut self) { | |
| 542 | + self.border_width.value = self.original_values.border_width; | |
| 543 | + self.border_color_focused.value = self.original_values.border_color_focused.clone(); | |
| 544 | + self.border_color_unfocused.value = self.original_values.border_color_unfocused.clone(); | |
| 545 | + self.gap_inner.value = self.original_values.gap_inner; | |
| 546 | + self.gap_outer.value = self.original_values.gap_outer; | |
| 547 | + self.titlebar_enabled.value = self.original_values.titlebar_enabled; | |
| 548 | + self.titlebar_height.value = self.original_values.titlebar_height; | |
| 549 | + self.focus_follows_mouse.value = self.original_values.focus_follows_mouse; | |
| 550 | + self.mouse_follows_focus.value = self.original_values.mouse_follows_focus; | |
| 551 | + self.dirty = false; | |
| 552 | + } | |
| 553 | + | |
| 554 | + fn apply(&mut self) -> Result<()> { | |
| 555 | + if !self.adapter.is_connected() { | |
| 556 | + self.adapter.connect()?; | |
| 557 | + } | |
| 558 | + | |
| 559 | + // Send IPC commands for changed values | |
| 560 | + if self.border_width.value != self.original_values.border_width { | |
| 561 | + self.adapter.set_config_value( | |
| 562 | + "border_width", | |
| 563 | + serde_json::json!(self.border_width.value), | |
| 564 | + )?; | |
| 565 | + } | |
| 566 | + | |
| 567 | + if self.gap_inner.value != self.original_values.gap_inner { | |
| 568 | + self.adapter | |
| 569 | + .set_config_value("gap_inner", serde_json::json!(self.gap_inner.value))?; | |
| 570 | + } | |
| 571 | + | |
| 572 | + if self.gap_outer.value != self.original_values.gap_outer { | |
| 573 | + self.adapter | |
| 574 | + .set_config_value("gap_outer", serde_json::json!(self.gap_outer.value))?; | |
| 575 | + } | |
| 576 | + | |
| 577 | + if self.border_color_focused.value != self.original_values.border_color_focused { | |
| 578 | + self.adapter.set_config_value( | |
| 579 | + "border_color_focused", | |
| 580 | + serde_json::json!(self.border_color_focused.value), | |
| 581 | + )?; | |
| 582 | + } | |
| 583 | + | |
| 584 | + if self.border_color_unfocused.value != self.original_values.border_color_unfocused { | |
| 585 | + self.adapter.set_config_value( | |
| 586 | + "border_color_unfocused", | |
| 587 | + serde_json::json!(self.border_color_unfocused.value), | |
| 588 | + )?; | |
| 589 | + } | |
| 590 | + | |
| 591 | + if self.focus_follows_mouse.value != self.original_values.focus_follows_mouse { | |
| 592 | + self.adapter.set_config_value( | |
| 593 | + "focus_follows_mouse", | |
| 594 | + serde_json::json!(self.focus_follows_mouse.value), | |
| 595 | + )?; | |
| 596 | + } | |
| 597 | + | |
| 598 | + if self.mouse_follows_focus.value != self.original_values.mouse_follows_focus { | |
| 599 | + self.adapter.set_config_value( | |
| 600 | + "mouse_follows_focus", | |
| 601 | + serde_json::json!(self.mouse_follows_focus.value), | |
| 602 | + )?; | |
| 603 | + } | |
| 604 | + | |
| 605 | + if self.titlebar_enabled.value != self.original_values.titlebar_enabled { | |
| 606 | + self.adapter.set_config_value( | |
| 607 | + "titlebar_enabled", | |
| 608 | + serde_json::json!(self.titlebar_enabled.value), | |
| 609 | + )?; | |
| 610 | + } | |
| 611 | + | |
| 612 | + if self.titlebar_height.value != self.original_values.titlebar_height { | |
| 613 | + self.adapter.set_config_value( | |
| 614 | + "titlebar_height", | |
| 615 | + serde_json::json!(self.titlebar_height.value), | |
| 616 | + )?; | |
| 617 | + } | |
| 618 | + | |
| 619 | + // Update original values | |
| 620 | + self.original_values = GarValues { | |
| 621 | + border_width: self.border_width.value, | |
| 622 | + border_color_focused: self.border_color_focused.value.clone(), | |
| 623 | + border_color_unfocused: self.border_color_unfocused.value.clone(), | |
| 624 | + gap_inner: self.gap_inner.value, | |
| 625 | + gap_outer: self.gap_outer.value, | |
| 626 | + titlebar_enabled: self.titlebar_enabled.value, | |
| 627 | + titlebar_height: self.titlebar_height.value, | |
| 628 | + focus_follows_mouse: self.focus_follows_mouse.value, | |
| 629 | + mouse_follows_focus: self.mouse_follows_focus.value, | |
| 630 | + }; | |
| 631 | + | |
| 632 | + self.dirty = false; | |
| 633 | + Ok(()) | |
| 634 | + } | |
| 635 | + | |
| 636 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 637 | + // Could refresh values from status | |
| 638 | + } | |
| 639 | + | |
| 640 | + fn has_config_file(&self) -> bool { | |
| 641 | + true | |
| 642 | + } | |
| 643 | + | |
| 644 | + fn save_to_config(&mut self) -> Result<()> { | |
| 645 | + let mut writer = LuaConfigWriter::load()?; | |
| 646 | + | |
| 647 | + // Build settings from current values | |
| 648 | + let settings = GarSettings { | |
| 649 | + border_width: Some(self.border_width.value as u32), | |
| 650 | + gap_inner: Some(self.gap_inner.value as u32), | |
| 651 | + gap_outer: Some(self.gap_outer.value as u32), | |
| 652 | + border_color_focused: Some(self.border_color_focused.value.clone()), | |
| 653 | + border_color_unfocused: Some(self.border_color_unfocused.value.clone()), | |
| 654 | + border_color_urgent: None, // Not yet exposed in panel UI | |
| 655 | + focus_follows_mouse: Some(self.focus_follows_mouse.value), | |
| 656 | + }; | |
| 657 | + | |
| 658 | + // Write settings as gar.set() calls | |
| 659 | + writer.set_values(&settings.to_lua_statements()); | |
| 660 | + | |
| 661 | + // Save the file | |
| 662 | + writer.save()?; | |
| 663 | + | |
| 664 | + // Trigger reload | |
| 665 | + crate::config::reload_component("gar")?; | |
| 666 | + | |
| 667 | + Ok(()) | |
| 668 | + } | |
| 669 | + | |
| 670 | + fn set_instant_apply(&mut self, enabled: bool) { | |
| 671 | + self.instant_apply = enabled; | |
| 672 | + } | |
| 673 | + | |
| 674 | + fn instant_apply_enabled(&self) -> bool { | |
| 675 | + self.instant_apply | |
| 676 | + } | |
| 677 | + | |
| 678 | + fn apply_instant(&mut self) -> Result<()> { | |
| 679 | + if !self.adapter.is_connected() { | |
| 680 | + self.adapter.connect()?; | |
| 681 | + } | |
| 682 | + | |
| 683 | + // Apply only the pending change | |
| 684 | + if let Some(field) = self.pending_change.take() { | |
| 685 | + let result = match field { | |
| 686 | + "border_width" => self.adapter.set_config_value( | |
| 687 | + "border_width", | |
| 688 | + serde_json::json!(self.border_width.value), | |
| 689 | + ), | |
| 690 | + "gap_inner" => self.adapter.set_config_value( | |
| 691 | + "gap_inner", | |
| 692 | + serde_json::json!(self.gap_inner.value), | |
| 693 | + ), | |
| 694 | + "gap_outer" => self.adapter.set_config_value( | |
| 695 | + "gap_outer", | |
| 696 | + serde_json::json!(self.gap_outer.value), | |
| 697 | + ), | |
| 698 | + "border_color_focused" => self.adapter.set_config_value( | |
| 699 | + "border_color_focused", | |
| 700 | + serde_json::json!(self.border_color_focused.value), | |
| 701 | + ), | |
| 702 | + "border_color_unfocused" => self.adapter.set_config_value( | |
| 703 | + "border_color_unfocused", | |
| 704 | + serde_json::json!(self.border_color_unfocused.value), | |
| 705 | + ), | |
| 706 | + "focus_follows_mouse" => self.adapter.set_config_value( | |
| 707 | + "focus_follows_mouse", | |
| 708 | + serde_json::json!(self.focus_follows_mouse.value), | |
| 709 | + ), | |
| 710 | + "mouse_follows_focus" => self.adapter.set_config_value( | |
| 711 | + "mouse_follows_focus", | |
| 712 | + serde_json::json!(self.mouse_follows_focus.value), | |
| 713 | + ), | |
| 714 | + "titlebar_enabled" => self.adapter.set_config_value( | |
| 715 | + "titlebar_enabled", | |
| 716 | + serde_json::json!(self.titlebar_enabled.value), | |
| 717 | + ), | |
| 718 | + "titlebar_height" => self.adapter.set_config_value( | |
| 719 | + "titlebar_height", | |
| 720 | + serde_json::json!(self.titlebar_height.value), | |
| 721 | + ), | |
| 722 | + _ => Ok(()), | |
| 723 | + }; | |
| 724 | + | |
| 725 | + // Update original value for this field if successful | |
| 726 | + if result.is_ok() { | |
| 727 | + match field { | |
| 728 | + "border_width" => self.original_values.border_width = self.border_width.value, | |
| 729 | + "gap_inner" => self.original_values.gap_inner = self.gap_inner.value, | |
| 730 | + "gap_outer" => self.original_values.gap_outer = self.gap_outer.value, | |
| 731 | + "border_color_focused" => { | |
| 732 | + self.original_values.border_color_focused = self.border_color_focused.value.clone() | |
| 733 | + } | |
| 734 | + "border_color_unfocused" => { | |
| 735 | + self.original_values.border_color_unfocused = self.border_color_unfocused.value.clone() | |
| 736 | + } | |
| 737 | + "focus_follows_mouse" => { | |
| 738 | + self.original_values.focus_follows_mouse = self.focus_follows_mouse.value | |
| 739 | + } | |
| 740 | + "mouse_follows_focus" => { | |
| 741 | + self.original_values.mouse_follows_focus = self.mouse_follows_focus.value | |
| 742 | + } | |
| 743 | + "titlebar_enabled" => { | |
| 744 | + self.original_values.titlebar_enabled = self.titlebar_enabled.value | |
| 745 | + } | |
| 746 | + "titlebar_height" => { | |
| 747 | + self.original_values.titlebar_height = self.titlebar_height.value | |
| 748 | + } | |
| 749 | + _ => {} | |
| 750 | + } | |
| 751 | + self.check_dirty(); | |
| 752 | + } | |
| 753 | + | |
| 754 | + result | |
| 755 | + } else { | |
| 756 | + Ok(()) | |
| 757 | + } | |
| 758 | + } | |
| 759 | +} | |
gargears/src/panels/garbar.rsadded@@ -0,0 +1,524 @@ | ||
| 1 | +//! Configuration panel for garbar (status bar) | |
| 2 | + | |
| 3 | +use crate::config::{GarbarSettings, LuaConfigWriter}; | |
| 4 | +use crate::ipc::adapters::GarbarAdapter; | |
| 5 | +use crate::ui::widgets::{Button, NumberInput, Section, TextInput, Toggle, WidgetEvent}; | |
| 6 | +use crate::ui::{Panel, PanelAction}; | |
| 7 | +use anyhow::Result; | |
| 8 | +use gartk_core::{InputEvent, Key, Rect, Theme}; | |
| 9 | +use gartk_render::{Renderer, TextStyle}; | |
| 10 | + | |
| 11 | +/// Garbar configuration panel | |
| 12 | +pub struct GarbarPanel { | |
| 13 | + adapter: GarbarAdapter, | |
| 14 | + | |
| 15 | + // Sections | |
| 16 | + general_section: Section, | |
| 17 | + modules_left_section: Section, | |
| 18 | + modules_center_section: Section, | |
| 19 | + modules_right_section: Section, | |
| 20 | + | |
| 21 | + // General settings | |
| 22 | + height: NumberInput, | |
| 23 | + position: Toggle, // top/bottom | |
| 24 | + visible: Toggle, | |
| 25 | + | |
| 26 | + // Module lists (display only for now, editing is complex) | |
| 27 | + modules_left_display: TextInput, | |
| 28 | + modules_center_display: TextInput, | |
| 29 | + modules_right_display: TextInput, | |
| 30 | + | |
| 31 | + // Action buttons | |
| 32 | + apply_button: Button, | |
| 33 | + reset_button: Button, | |
| 34 | + reload_button: Button, | |
| 35 | + save_button: Button, | |
| 36 | + | |
| 37 | + // State | |
| 38 | + original_values: GarbarValues, | |
| 39 | + dirty: bool, | |
| 40 | + scroll_offset: i32, | |
| 41 | + instant_apply: bool, | |
| 42 | + pending_change: Option<&'static str>, | |
| 43 | +} | |
| 44 | + | |
| 45 | +#[derive(Clone, Default)] | |
| 46 | +struct GarbarValues { | |
| 47 | + height: i32, | |
| 48 | + position_top: bool, | |
| 49 | + visible: bool, | |
| 50 | + modules_left: String, | |
| 51 | + modules_center: String, | |
| 52 | + modules_right: String, | |
| 53 | +} | |
| 54 | + | |
| 55 | +impl GarbarPanel { | |
| 56 | + pub fn new(mut adapter: GarbarAdapter) -> Self { | |
| 57 | + let values = if adapter.is_connected() { | |
| 58 | + Self::fetch_values(&mut adapter) | |
| 59 | + } else { | |
| 60 | + GarbarValues::default() | |
| 61 | + }; | |
| 62 | + | |
| 63 | + Self { | |
| 64 | + adapter, | |
| 65 | + general_section: Section::new("General"), | |
| 66 | + modules_left_section: Section::new("Modules Left"), | |
| 67 | + modules_center_section: Section::new("Modules Center"), | |
| 68 | + modules_right_section: Section::new("Modules Right"), | |
| 69 | + height: NumberInput::new("Height", values.height) | |
| 70 | + .with_range(16, 64) | |
| 71 | + .with_step(2), | |
| 72 | + position: Toggle::new("Position Top", values.position_top), | |
| 73 | + visible: Toggle::new("Visible", values.visible), | |
| 74 | + modules_left_display: TextInput::new("Modules", &values.modules_left) | |
| 75 | + .with_placeholder("workspaces, title"), | |
| 76 | + modules_center_display: TextInput::new("Modules", &values.modules_center) | |
| 77 | + .with_placeholder("clock"), | |
| 78 | + modules_right_display: TextInput::new("Modules", &values.modules_right) | |
| 79 | + .with_placeholder("cpu, memory, battery"), | |
| 80 | + apply_button: Button::new("Apply").primary(), | |
| 81 | + reset_button: Button::new("Reset"), | |
| 82 | + reload_button: Button::new("Reload"), | |
| 83 | + save_button: Button::new("Save"), | |
| 84 | + original_values: values, | |
| 85 | + dirty: false, | |
| 86 | + scroll_offset: 0, | |
| 87 | + instant_apply: true, | |
| 88 | + pending_change: None, | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + fn fetch_values(adapter: &mut GarbarAdapter) -> GarbarValues { | |
| 93 | + if let Ok(status) = adapter.status() { | |
| 94 | + let position_top = status | |
| 95 | + .position | |
| 96 | + .as_ref() | |
| 97 | + .map(|p| p == "top") | |
| 98 | + .unwrap_or(true); | |
| 99 | + GarbarValues { | |
| 100 | + height: status.height as i32, | |
| 101 | + position_top, | |
| 102 | + visible: status.visible, | |
| 103 | + modules_left: status.modules_left.join(", "), | |
| 104 | + modules_center: status.modules_center.join(", "), | |
| 105 | + modules_right: status.modules_right.join(", "), | |
| 106 | + } | |
| 107 | + } else { | |
| 108 | + GarbarValues { | |
| 109 | + height: 28, | |
| 110 | + position_top: true, | |
| 111 | + visible: true, | |
| 112 | + modules_left: "workspaces".into(), | |
| 113 | + modules_center: String::new(), | |
| 114 | + modules_right: "clock".into(), | |
| 115 | + } | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + fn check_dirty(&mut self) { | |
| 120 | + self.dirty = self.height.value != self.original_values.height | |
| 121 | + || self.position.value != self.original_values.position_top | |
| 122 | + || self.visible.value != self.original_values.visible | |
| 123 | + || self.modules_left_display.value != self.original_values.modules_left | |
| 124 | + || self.modules_center_display.value != self.original_values.modules_center | |
| 125 | + || self.modules_right_display.value != self.original_values.modules_right; | |
| 126 | + } | |
| 127 | + | |
| 128 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 129 | + let padding = theme.padding as i32; | |
| 130 | + let row_height = 36; | |
| 131 | + let section_height = 32; | |
| 132 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 133 | + | |
| 134 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 135 | + | |
| 136 | + // General section | |
| 137 | + self.general_section.bounds = | |
| 138 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 139 | + y += section_height; | |
| 140 | + | |
| 141 | + if self.general_section.expanded { | |
| 142 | + self.height.bounds = | |
| 143 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 144 | + y += row_height; | |
| 145 | + | |
| 146 | + self.position.bounds = | |
| 147 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 148 | + y += row_height; | |
| 149 | + | |
| 150 | + self.visible.bounds = | |
| 151 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 152 | + y += row_height; | |
| 153 | + } | |
| 154 | + | |
| 155 | + y += padding / 2; | |
| 156 | + | |
| 157 | + // Modules Left section | |
| 158 | + self.modules_left_section.bounds = | |
| 159 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 160 | + y += section_height; | |
| 161 | + | |
| 162 | + if self.modules_left_section.expanded { | |
| 163 | + self.modules_left_display.bounds = | |
| 164 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 165 | + y += row_height; | |
| 166 | + } | |
| 167 | + | |
| 168 | + y += padding / 2; | |
| 169 | + | |
| 170 | + // Modules Center section | |
| 171 | + self.modules_center_section.bounds = | |
| 172 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 173 | + y += section_height; | |
| 174 | + | |
| 175 | + if self.modules_center_section.expanded { | |
| 176 | + self.modules_center_display.bounds = | |
| 177 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 178 | + y += row_height; | |
| 179 | + } | |
| 180 | + | |
| 181 | + y += padding / 2; | |
| 182 | + | |
| 183 | + // Modules Right section | |
| 184 | + self.modules_right_section.bounds = | |
| 185 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 186 | + y += section_height; | |
| 187 | + | |
| 188 | + if self.modules_right_section.expanded { | |
| 189 | + self.modules_right_display.bounds = | |
| 190 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // Action buttons at bottom | |
| 194 | + let button_y = bounds.y + bounds.height as i32 - padding - 36; | |
| 195 | + let button_width = 68; | |
| 196 | + | |
| 197 | + self.reload_button.bounds = Rect::new( | |
| 198 | + bounds.x + bounds.width as i32 - padding - button_width * 4 - 24, | |
| 199 | + button_y, | |
| 200 | + button_width as u32, | |
| 201 | + 32, | |
| 202 | + ); | |
| 203 | + | |
| 204 | + self.reset_button.bounds = Rect::new( | |
| 205 | + bounds.x + bounds.width as i32 - padding - button_width * 3 - 16, | |
| 206 | + button_y, | |
| 207 | + button_width as u32, | |
| 208 | + 32, | |
| 209 | + ); | |
| 210 | + | |
| 211 | + self.apply_button.bounds = Rect::new( | |
| 212 | + bounds.x + bounds.width as i32 - padding - button_width * 2 - 8, | |
| 213 | + button_y, | |
| 214 | + button_width as u32, | |
| 215 | + 32, | |
| 216 | + ); | |
| 217 | + | |
| 218 | + self.save_button.bounds = Rect::new( | |
| 219 | + bounds.x + bounds.width as i32 - padding - button_width, | |
| 220 | + button_y, | |
| 221 | + button_width as u32, | |
| 222 | + 32, | |
| 223 | + ); | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +impl Panel for GarbarPanel { | |
| 228 | + fn name(&self) -> &str { | |
| 229 | + "garbar" | |
| 230 | + } | |
| 231 | + | |
| 232 | + fn description(&self) -> &str { | |
| 233 | + "Status bar" | |
| 234 | + } | |
| 235 | + | |
| 236 | + fn is_dirty(&self) -> bool { | |
| 237 | + self.dirty | |
| 238 | + } | |
| 239 | + | |
| 240 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 241 | + self.layout_widgets(bounds, theme); | |
| 242 | + | |
| 243 | + // Render sections and widgets | |
| 244 | + self.general_section.render(renderer, theme)?; | |
| 245 | + if self.general_section.expanded { | |
| 246 | + self.height.render(renderer, theme)?; | |
| 247 | + self.position.render(renderer, theme)?; | |
| 248 | + self.visible.render(renderer, theme)?; | |
| 249 | + } | |
| 250 | + | |
| 251 | + self.modules_left_section.render(renderer, theme)?; | |
| 252 | + if self.modules_left_section.expanded { | |
| 253 | + self.modules_left_display.render(renderer, theme)?; | |
| 254 | + } | |
| 255 | + | |
| 256 | + self.modules_center_section.render(renderer, theme)?; | |
| 257 | + if self.modules_center_section.expanded { | |
| 258 | + self.modules_center_display.render(renderer, theme)?; | |
| 259 | + } | |
| 260 | + | |
| 261 | + self.modules_right_section.render(renderer, theme)?; | |
| 262 | + if self.modules_right_section.expanded { | |
| 263 | + self.modules_right_display.render(renderer, theme)?; | |
| 264 | + } | |
| 265 | + | |
| 266 | + // Render action buttons | |
| 267 | + self.reload_button.render(renderer, theme)?; | |
| 268 | + self.reset_button.render(renderer, theme)?; | |
| 269 | + self.apply_button.render(renderer, theme)?; | |
| 270 | + self.save_button.render(renderer, theme)?; | |
| 271 | + | |
| 272 | + // Dirty indicator | |
| 273 | + if self.dirty { | |
| 274 | + let indicator_style = TextStyle::new() | |
| 275 | + .font_family(&theme.font_family) | |
| 276 | + .font_size(theme.font_size * 0.75) | |
| 277 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 278 | + | |
| 279 | + renderer.text( | |
| 280 | + "● Unsaved changes", | |
| 281 | + (bounds.x + theme.padding as i32) as f64, | |
| 282 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 28) as f64, | |
| 283 | + &indicator_style, | |
| 284 | + )?; | |
| 285 | + } | |
| 286 | + | |
| 287 | + Ok(()) | |
| 288 | + } | |
| 289 | + | |
| 290 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 291 | + match event { | |
| 292 | + InputEvent::Key(ke) if ke.pressed => { | |
| 293 | + // Handle text input keys | |
| 294 | + if let WidgetEvent::Changed = self.modules_left_display.on_key(&ke.key) { | |
| 295 | + self.check_dirty(); | |
| 296 | + return PanelAction::Redraw; | |
| 297 | + } | |
| 298 | + if let WidgetEvent::Changed = self.modules_center_display.on_key(&ke.key) { | |
| 299 | + self.check_dirty(); | |
| 300 | + return PanelAction::Redraw; | |
| 301 | + } | |
| 302 | + if let WidgetEvent::Changed = self.modules_right_display.on_key(&ke.key) { | |
| 303 | + self.check_dirty(); | |
| 304 | + return PanelAction::Redraw; | |
| 305 | + } | |
| 306 | + | |
| 307 | + // Scroll with Page Up/Down | |
| 308 | + match &ke.key { | |
| 309 | + Key::PageUp => { | |
| 310 | + self.scroll_offset = (self.scroll_offset - 100).max(0); | |
| 311 | + return PanelAction::Redraw; | |
| 312 | + } | |
| 313 | + Key::PageDown => { | |
| 314 | + self.scroll_offset += 100; | |
| 315 | + return PanelAction::Redraw; | |
| 316 | + } | |
| 317 | + _ => {} | |
| 318 | + } | |
| 319 | + | |
| 320 | + PanelAction::None | |
| 321 | + } | |
| 322 | + InputEvent::MousePress(me) => { | |
| 323 | + let x = me.position.x; | |
| 324 | + let y = me.position.y; | |
| 325 | + | |
| 326 | + // Check sections | |
| 327 | + if let WidgetEvent::Changed = self.general_section.on_click(x, y) { | |
| 328 | + return PanelAction::Redraw; | |
| 329 | + } | |
| 330 | + if let WidgetEvent::Changed = self.modules_left_section.on_click(x, y) { | |
| 331 | + return PanelAction::Redraw; | |
| 332 | + } | |
| 333 | + if let WidgetEvent::Changed = self.modules_center_section.on_click(x, y) { | |
| 334 | + return PanelAction::Redraw; | |
| 335 | + } | |
| 336 | + if let WidgetEvent::Changed = self.modules_right_section.on_click(x, y) { | |
| 337 | + return PanelAction::Redraw; | |
| 338 | + } | |
| 339 | + | |
| 340 | + // Check widgets - with instant apply support | |
| 341 | + if let WidgetEvent::Changed = self.height.on_click(x, y) { | |
| 342 | + self.check_dirty(); | |
| 343 | + // Height change needs config save + reload, no instant apply | |
| 344 | + return PanelAction::Redraw; | |
| 345 | + } | |
| 346 | + if let WidgetEvent::Changed = self.position.on_click(x, y) { | |
| 347 | + self.check_dirty(); | |
| 348 | + // Position change needs config save + reload, no instant apply | |
| 349 | + return PanelAction::Redraw; | |
| 350 | + } | |
| 351 | + if let WidgetEvent::Changed = self.visible.on_click(x, y) { | |
| 352 | + self.check_dirty(); | |
| 353 | + self.pending_change = Some("visible"); | |
| 354 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 355 | + } | |
| 356 | + | |
| 357 | + // Text inputs | |
| 358 | + self.modules_left_display.on_click(x, y); | |
| 359 | + self.modules_center_display.on_click(x, y); | |
| 360 | + self.modules_right_display.on_click(x, y); | |
| 361 | + | |
| 362 | + // Action buttons | |
| 363 | + if let WidgetEvent::Clicked = self.apply_button.on_click(x, y) { | |
| 364 | + return PanelAction::Apply; | |
| 365 | + } | |
| 366 | + if let WidgetEvent::Clicked = self.reset_button.on_click(x, y) { | |
| 367 | + return PanelAction::Reset; | |
| 368 | + } | |
| 369 | + if let WidgetEvent::Clicked = self.reload_button.on_click(x, y) { | |
| 370 | + // Special: reload config | |
| 371 | + let _ = self.adapter.reload(); | |
| 372 | + return PanelAction::Redraw; | |
| 373 | + } | |
| 374 | + if let WidgetEvent::Clicked = self.save_button.on_click(x, y) { | |
| 375 | + return PanelAction::Save; | |
| 376 | + } | |
| 377 | + | |
| 378 | + PanelAction::Redraw | |
| 379 | + } | |
| 380 | + InputEvent::Scroll(se) => { | |
| 381 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 382 | + PanelAction::Redraw | |
| 383 | + } | |
| 384 | + _ => PanelAction::None, | |
| 385 | + } | |
| 386 | + } | |
| 387 | + | |
| 388 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 389 | + let mut changed = false; | |
| 390 | + changed |= self.general_section.on_mouse_move(x, y); | |
| 391 | + changed |= self.modules_left_section.on_mouse_move(x, y); | |
| 392 | + changed |= self.modules_center_section.on_mouse_move(x, y); | |
| 393 | + changed |= self.modules_right_section.on_mouse_move(x, y); | |
| 394 | + | |
| 395 | + changed |= self.height.on_mouse_move(x, y); | |
| 396 | + changed |= self.position.on_mouse_move(x, y); | |
| 397 | + changed |= self.visible.on_mouse_move(x, y); | |
| 398 | + changed |= self.modules_left_display.on_mouse_move(x, y); | |
| 399 | + changed |= self.modules_center_display.on_mouse_move(x, y); | |
| 400 | + changed |= self.modules_right_display.on_mouse_move(x, y); | |
| 401 | + | |
| 402 | + changed |= self.apply_button.on_mouse_move(x, y); | |
| 403 | + changed |= self.reset_button.on_mouse_move(x, y); | |
| 404 | + changed |= self.reload_button.on_mouse_move(x, y); | |
| 405 | + changed |= self.save_button.on_mouse_move(x, y); | |
| 406 | + changed | |
| 407 | + } | |
| 408 | + | |
| 409 | + fn reset(&mut self) { | |
| 410 | + self.height.value = self.original_values.height; | |
| 411 | + self.position.value = self.original_values.position_top; | |
| 412 | + self.visible.value = self.original_values.visible; | |
| 413 | + self.modules_left_display.value = self.original_values.modules_left.clone(); | |
| 414 | + self.modules_center_display.value = self.original_values.modules_center.clone(); | |
| 415 | + self.modules_right_display.value = self.original_values.modules_right.clone(); | |
| 416 | + self.dirty = false; | |
| 417 | + } | |
| 418 | + | |
| 419 | + fn apply(&mut self) -> Result<()> { | |
| 420 | + if !self.adapter.is_connected() { | |
| 421 | + self.adapter.connect()?; | |
| 422 | + } | |
| 423 | + | |
| 424 | + // Toggle visibility if changed | |
| 425 | + if self.visible.value != self.original_values.visible { | |
| 426 | + if self.visible.value { | |
| 427 | + self.adapter.show()?; | |
| 428 | + } else { | |
| 429 | + self.adapter.hide()?; | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + // Note: height and module changes require config file edit + reload | |
| 434 | + // For now we just reload to pick up any manual config changes | |
| 435 | + if self.height.value != self.original_values.height { | |
| 436 | + // Would need to edit Lua config and reload | |
| 437 | + self.adapter.reload()?; | |
| 438 | + } | |
| 439 | + | |
| 440 | + // Update original values | |
| 441 | + self.original_values = GarbarValues { | |
| 442 | + height: self.height.value, | |
| 443 | + position_top: self.position.value, | |
| 444 | + visible: self.visible.value, | |
| 445 | + modules_left: self.modules_left_display.value.clone(), | |
| 446 | + modules_center: self.modules_center_display.value.clone(), | |
| 447 | + modules_right: self.modules_right_display.value.clone(), | |
| 448 | + }; | |
| 449 | + | |
| 450 | + self.dirty = false; | |
| 451 | + Ok(()) | |
| 452 | + } | |
| 453 | + | |
| 454 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 455 | + // Could refresh values from status | |
| 456 | + } | |
| 457 | + | |
| 458 | + fn has_config_file(&self) -> bool { | |
| 459 | + true | |
| 460 | + } | |
| 461 | + | |
| 462 | + fn save_to_config(&mut self) -> Result<()> { | |
| 463 | + let mut writer = LuaConfigWriter::load()?; | |
| 464 | + | |
| 465 | + // Parse module lists from comma-separated strings | |
| 466 | + let parse_modules = |s: &str| -> Vec<String> { | |
| 467 | + s.split(',') | |
| 468 | + .map(|m| m.trim().to_string()) | |
| 469 | + .filter(|m| !m.is_empty()) | |
| 470 | + .collect() | |
| 471 | + }; | |
| 472 | + | |
| 473 | + let settings = GarbarSettings { | |
| 474 | + height: Some(self.height.value as u32), | |
| 475 | + position: Some(if self.position.value { "top" } else { "bottom" }.to_string()), | |
| 476 | + modules_left: Some(parse_modules(&self.modules_left_display.value)), | |
| 477 | + modules_center: Some(parse_modules(&self.modules_center_display.value)), | |
| 478 | + modules_right: Some(parse_modules(&self.modules_right_display.value)), | |
| 479 | + ..Default::default() | |
| 480 | + }; | |
| 481 | + | |
| 482 | + writer.set_bar_table(&settings); | |
| 483 | + writer.save()?; | |
| 484 | + | |
| 485 | + // Trigger reload | |
| 486 | + crate::config::reload_component("garbar")?; | |
| 487 | + | |
| 488 | + Ok(()) | |
| 489 | + } | |
| 490 | + | |
| 491 | + fn set_instant_apply(&mut self, enabled: bool) { | |
| 492 | + self.instant_apply = enabled; | |
| 493 | + } | |
| 494 | + | |
| 495 | + fn instant_apply_enabled(&self) -> bool { | |
| 496 | + self.instant_apply | |
| 497 | + } | |
| 498 | + | |
| 499 | + fn apply_instant(&mut self) -> Result<()> { | |
| 500 | + if !self.adapter.is_connected() { | |
| 501 | + self.adapter.connect()?; | |
| 502 | + } | |
| 503 | + | |
| 504 | + // Apply only the pending change | |
| 505 | + if let Some(field) = self.pending_change.take() { | |
| 506 | + match field { | |
| 507 | + "visible" => { | |
| 508 | + if self.visible.value != self.original_values.visible { | |
| 509 | + if self.visible.value { | |
| 510 | + self.adapter.show()?; | |
| 511 | + } else { | |
| 512 | + self.adapter.hide()?; | |
| 513 | + } | |
| 514 | + self.original_values.visible = self.visible.value; | |
| 515 | + } | |
| 516 | + } | |
| 517 | + _ => {} | |
| 518 | + } | |
| 519 | + self.check_dirty(); | |
| 520 | + } | |
| 521 | + | |
| 522 | + Ok(()) | |
| 523 | + } | |
| 524 | +} | |
gargears/src/panels/garbg.rsadded@@ -0,0 +1,541 @@ | ||
| 1 | +//! Configuration panel for garbg (wallpaper daemon) | |
| 2 | + | |
| 3 | +use crate::config::{GarbgConfig, GarbgSlideshow}; | |
| 4 | +use crate::ipc::adapters::GarbgAdapter; | |
| 5 | +use crate::ui::widgets::{Button, Label, NumberInput, Section, Toggle, WidgetEvent}; | |
| 6 | +use crate::ui::{Panel, PanelAction}; | |
| 7 | +use anyhow::Result; | |
| 8 | +use gartk_core::{InputEvent, Key, Rect, Theme}; | |
| 9 | +use gartk_render::{Renderer, TextStyle}; | |
| 10 | + | |
| 11 | +/// Garbg configuration panel | |
| 12 | +pub struct GarbgPanel { | |
| 13 | + adapter: GarbgAdapter, | |
| 14 | + | |
| 15 | + // Sections | |
| 16 | + current_section: Section, | |
| 17 | + playlist_section: Section, | |
| 18 | + slideshow_section: Section, | |
| 19 | + controls_section: Section, | |
| 20 | + | |
| 21 | + // Current wallpaper info (read-only display) | |
| 22 | + current_source: Label, | |
| 23 | + current_wallpaper: Label, | |
| 24 | + playlist_position: Label, | |
| 25 | + | |
| 26 | + // Slideshow settings | |
| 27 | + interval: NumberInput, | |
| 28 | + paused: Toggle, | |
| 29 | + | |
| 30 | + // Control buttons | |
| 31 | + prev_button: Button, | |
| 32 | + next_button: Button, | |
| 33 | + pause_button: Button, | |
| 34 | + | |
| 35 | + // Action buttons | |
| 36 | + apply_button: Button, | |
| 37 | + reset_button: Button, | |
| 38 | + save_button: Button, | |
| 39 | + | |
| 40 | + // State | |
| 41 | + original_values: GarbgValues, | |
| 42 | + dirty: bool, | |
| 43 | + scroll_offset: i32, | |
| 44 | + instant_apply: bool, | |
| 45 | + pending_change: Option<&'static str>, | |
| 46 | +} | |
| 47 | + | |
| 48 | +#[derive(Clone, Default)] | |
| 49 | +struct GarbgValues { | |
| 50 | + interval: i32, | |
| 51 | + paused: bool, | |
| 52 | +} | |
| 53 | + | |
| 54 | +impl GarbgPanel { | |
| 55 | + pub fn new(mut adapter: GarbgAdapter) -> Self { | |
| 56 | + let (values, current_source, current_wallpaper, playlist_pos) = if adapter.is_connected() { | |
| 57 | + Self::fetch_values(&mut adapter) | |
| 58 | + } else { | |
| 59 | + ( | |
| 60 | + GarbgValues::default(), | |
| 61 | + "Not connected".to_string(), | |
| 62 | + "N/A".to_string(), | |
| 63 | + "N/A".to_string(), | |
| 64 | + ) | |
| 65 | + }; | |
| 66 | + | |
| 67 | + Self { | |
| 68 | + adapter, | |
| 69 | + current_section: Section::new("Current Wallpaper"), | |
| 70 | + playlist_section: Section::new("Playlist"), | |
| 71 | + slideshow_section: Section::new("Slideshow"), | |
| 72 | + controls_section: Section::new("Controls"), | |
| 73 | + current_source: Label::new("Source", ¤t_source), | |
| 74 | + current_wallpaper: Label::new("File", ¤t_wallpaper), | |
| 75 | + playlist_position: Label::new("Position", &playlist_pos), | |
| 76 | + interval: NumberInput::new("Interval (sec)", values.interval) | |
| 77 | + .with_range(5, 3600) | |
| 78 | + .with_step(5), | |
| 79 | + paused: Toggle::new("Paused", values.paused), | |
| 80 | + prev_button: Button::new("◀ Prev"), | |
| 81 | + next_button: Button::new("Next ▶"), | |
| 82 | + pause_button: Button::new(if values.paused { "Resume" } else { "Pause" }), | |
| 83 | + apply_button: Button::new("Apply").primary(), | |
| 84 | + reset_button: Button::new("Reset"), | |
| 85 | + save_button: Button::new("Save"), | |
| 86 | + original_values: values, | |
| 87 | + dirty: false, | |
| 88 | + scroll_offset: 0, | |
| 89 | + instant_apply: true, | |
| 90 | + pending_change: None, | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + fn fetch_values( | |
| 95 | + adapter: &mut GarbgAdapter, | |
| 96 | + ) -> (GarbgValues, String, String, String) { | |
| 97 | + if let Ok(status) = adapter.status() { | |
| 98 | + let source = status.current_source.unwrap_or_else(|| "Unknown".into()); | |
| 99 | + let wallpaper = status | |
| 100 | + .current_wallpaper | |
| 101 | + .map(|w| { | |
| 102 | + // Shorten long paths | |
| 103 | + if w.len() > 40 { | |
| 104 | + format!("...{}", &w[w.len() - 37..]) | |
| 105 | + } else { | |
| 106 | + w | |
| 107 | + } | |
| 108 | + }) | |
| 109 | + .unwrap_or_else(|| "None".into()); | |
| 110 | + let playlist_pos = match (status.playlist_index, status.playlist_total) { | |
| 111 | + (Some(idx), Some(total)) => format!("{} / {}", idx + 1, total), | |
| 112 | + _ => "N/A".into(), | |
| 113 | + }; | |
| 114 | + | |
| 115 | + ( | |
| 116 | + GarbgValues { | |
| 117 | + interval: status.interval_secs.unwrap_or(300) as i32, | |
| 118 | + paused: status.paused, | |
| 119 | + }, | |
| 120 | + source, | |
| 121 | + wallpaper, | |
| 122 | + playlist_pos, | |
| 123 | + ) | |
| 124 | + } else { | |
| 125 | + ( | |
| 126 | + GarbgValues { | |
| 127 | + interval: 300, | |
| 128 | + paused: false, | |
| 129 | + }, | |
| 130 | + "Unknown".into(), | |
| 131 | + "None".into(), | |
| 132 | + "N/A".into(), | |
| 133 | + ) | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + fn refresh_status(&mut self) { | |
| 138 | + if let Ok(status) = self.adapter.status() { | |
| 139 | + self.current_source.text = status.current_source.unwrap_or_else(|| "Unknown".into()); | |
| 140 | + self.current_wallpaper.text = status | |
| 141 | + .current_wallpaper | |
| 142 | + .map(|w| { | |
| 143 | + if w.len() > 40 { | |
| 144 | + format!("...{}", &w[w.len() - 37..]) | |
| 145 | + } else { | |
| 146 | + w | |
| 147 | + } | |
| 148 | + }) | |
| 149 | + .unwrap_or_else(|| "None".into()); | |
| 150 | + self.playlist_position.text = match (status.playlist_index, status.playlist_total) { | |
| 151 | + (Some(idx), Some(total)) => format!("{} / {}", idx + 1, total), | |
| 152 | + _ => "N/A".into(), | |
| 153 | + }; | |
| 154 | + self.paused.value = status.paused; | |
| 155 | + self.pause_button = Button::new(if status.paused { "Resume" } else { "Pause" }); | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + fn check_dirty(&mut self) { | |
| 160 | + self.dirty = self.interval.value != self.original_values.interval | |
| 161 | + || self.paused.value != self.original_values.paused; | |
| 162 | + } | |
| 163 | + | |
| 164 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 165 | + let padding = theme.padding as i32; | |
| 166 | + let row_height = 36; | |
| 167 | + let section_height = 32; | |
| 168 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 169 | + | |
| 170 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 171 | + | |
| 172 | + // Current section | |
| 173 | + self.current_section.bounds = | |
| 174 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 175 | + y += section_height; | |
| 176 | + | |
| 177 | + if self.current_section.expanded { | |
| 178 | + self.current_source.bounds = | |
| 179 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 180 | + y += row_height; | |
| 181 | + | |
| 182 | + self.current_wallpaper.bounds = | |
| 183 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 184 | + y += row_height; | |
| 185 | + } | |
| 186 | + | |
| 187 | + y += padding / 2; | |
| 188 | + | |
| 189 | + // Playlist section | |
| 190 | + self.playlist_section.bounds = | |
| 191 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 192 | + y += section_height; | |
| 193 | + | |
| 194 | + if self.playlist_section.expanded { | |
| 195 | + self.playlist_position.bounds = | |
| 196 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 197 | + y += row_height; | |
| 198 | + } | |
| 199 | + | |
| 200 | + y += padding / 2; | |
| 201 | + | |
| 202 | + // Slideshow section | |
| 203 | + self.slideshow_section.bounds = | |
| 204 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 205 | + y += section_height; | |
| 206 | + | |
| 207 | + if self.slideshow_section.expanded { | |
| 208 | + self.interval.bounds = | |
| 209 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 210 | + y += row_height; | |
| 211 | + | |
| 212 | + self.paused.bounds = | |
| 213 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 214 | + y += row_height; | |
| 215 | + } | |
| 216 | + | |
| 217 | + y += padding / 2; | |
| 218 | + | |
| 219 | + // Controls section | |
| 220 | + self.controls_section.bounds = | |
| 221 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 222 | + y += section_height; | |
| 223 | + | |
| 224 | + if self.controls_section.expanded { | |
| 225 | + let control_button_width = 80; | |
| 226 | + let control_y = y + 4; | |
| 227 | + | |
| 228 | + self.prev_button.bounds = Rect::new( | |
| 229 | + bounds.x + padding + 16, | |
| 230 | + control_y, | |
| 231 | + control_button_width as u32, | |
| 232 | + 28, | |
| 233 | + ); | |
| 234 | + | |
| 235 | + self.pause_button.bounds = Rect::new( | |
| 236 | + bounds.x + padding + 16 + control_button_width + 8, | |
| 237 | + control_y, | |
| 238 | + control_button_width as u32, | |
| 239 | + 28, | |
| 240 | + ); | |
| 241 | + | |
| 242 | + self.next_button.bounds = Rect::new( | |
| 243 | + bounds.x + padding + 16 + (control_button_width + 8) * 2, | |
| 244 | + control_y, | |
| 245 | + control_button_width as u32, | |
| 246 | + 28, | |
| 247 | + ); | |
| 248 | + } | |
| 249 | + | |
| 250 | + // Action buttons at bottom | |
| 251 | + let button_y = bounds.y + bounds.height as i32 - padding - 36; | |
| 252 | + let button_width = 80; | |
| 253 | + | |
| 254 | + self.reset_button.bounds = Rect::new( | |
| 255 | + bounds.x + bounds.width as i32 - padding - button_width * 3 - 16, | |
| 256 | + button_y, | |
| 257 | + button_width as u32, | |
| 258 | + 32, | |
| 259 | + ); | |
| 260 | + | |
| 261 | + self.apply_button.bounds = Rect::new( | |
| 262 | + bounds.x + bounds.width as i32 - padding - button_width * 2 - 8, | |
| 263 | + button_y, | |
| 264 | + button_width as u32, | |
| 265 | + 32, | |
| 266 | + ); | |
| 267 | + | |
| 268 | + self.save_button.bounds = Rect::new( | |
| 269 | + bounds.x + bounds.width as i32 - padding - button_width, | |
| 270 | + button_y, | |
| 271 | + button_width as u32, | |
| 272 | + 32, | |
| 273 | + ); | |
| 274 | + } | |
| 275 | +} | |
| 276 | + | |
| 277 | +impl Panel for GarbgPanel { | |
| 278 | + fn name(&self) -> &str { | |
| 279 | + "garbg" | |
| 280 | + } | |
| 281 | + | |
| 282 | + fn description(&self) -> &str { | |
| 283 | + "Wallpaper daemon" | |
| 284 | + } | |
| 285 | + | |
| 286 | + fn is_dirty(&self) -> bool { | |
| 287 | + self.dirty | |
| 288 | + } | |
| 289 | + | |
| 290 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 291 | + self.layout_widgets(bounds, theme); | |
| 292 | + | |
| 293 | + // Render sections and widgets | |
| 294 | + self.current_section.render(renderer, theme)?; | |
| 295 | + if self.current_section.expanded { | |
| 296 | + self.current_source.render(renderer, theme)?; | |
| 297 | + self.current_wallpaper.render(renderer, theme)?; | |
| 298 | + } | |
| 299 | + | |
| 300 | + self.playlist_section.render(renderer, theme)?; | |
| 301 | + if self.playlist_section.expanded { | |
| 302 | + self.playlist_position.render(renderer, theme)?; | |
| 303 | + } | |
| 304 | + | |
| 305 | + self.slideshow_section.render(renderer, theme)?; | |
| 306 | + if self.slideshow_section.expanded { | |
| 307 | + self.interval.render(renderer, theme)?; | |
| 308 | + self.paused.render(renderer, theme)?; | |
| 309 | + } | |
| 310 | + | |
| 311 | + self.controls_section.render(renderer, theme)?; | |
| 312 | + if self.controls_section.expanded { | |
| 313 | + self.prev_button.render(renderer, theme)?; | |
| 314 | + self.pause_button.render(renderer, theme)?; | |
| 315 | + self.next_button.render(renderer, theme)?; | |
| 316 | + } | |
| 317 | + | |
| 318 | + // Render action buttons | |
| 319 | + self.reset_button.render(renderer, theme)?; | |
| 320 | + self.apply_button.render(renderer, theme)?; | |
| 321 | + self.save_button.render(renderer, theme)?; | |
| 322 | + | |
| 323 | + // Dirty indicator | |
| 324 | + if self.dirty { | |
| 325 | + let indicator_style = TextStyle::new() | |
| 326 | + .font_family(&theme.font_family) | |
| 327 | + .font_size(theme.font_size * 0.75) | |
| 328 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 329 | + | |
| 330 | + renderer.text( | |
| 331 | + "● Unsaved changes", | |
| 332 | + (bounds.x + theme.padding as i32) as f64, | |
| 333 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 28) as f64, | |
| 334 | + &indicator_style, | |
| 335 | + )?; | |
| 336 | + } | |
| 337 | + | |
| 338 | + Ok(()) | |
| 339 | + } | |
| 340 | + | |
| 341 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 342 | + match event { | |
| 343 | + InputEvent::Key(ke) if ke.pressed => { | |
| 344 | + // Scroll with Page Up/Down | |
| 345 | + match &ke.key { | |
| 346 | + Key::PageUp => { | |
| 347 | + self.scroll_offset = (self.scroll_offset - 100).max(0); | |
| 348 | + return PanelAction::Redraw; | |
| 349 | + } | |
| 350 | + Key::PageDown => { | |
| 351 | + self.scroll_offset += 100; | |
| 352 | + return PanelAction::Redraw; | |
| 353 | + } | |
| 354 | + _ => {} | |
| 355 | + } | |
| 356 | + | |
| 357 | + PanelAction::None | |
| 358 | + } | |
| 359 | + InputEvent::MousePress(me) => { | |
| 360 | + let x = me.position.x; | |
| 361 | + let y = me.position.y; | |
| 362 | + | |
| 363 | + // Check sections | |
| 364 | + if let WidgetEvent::Changed = self.current_section.on_click(x, y) { | |
| 365 | + return PanelAction::Redraw; | |
| 366 | + } | |
| 367 | + if let WidgetEvent::Changed = self.playlist_section.on_click(x, y) { | |
| 368 | + return PanelAction::Redraw; | |
| 369 | + } | |
| 370 | + if let WidgetEvent::Changed = self.slideshow_section.on_click(x, y) { | |
| 371 | + return PanelAction::Redraw; | |
| 372 | + } | |
| 373 | + if let WidgetEvent::Changed = self.controls_section.on_click(x, y) { | |
| 374 | + return PanelAction::Redraw; | |
| 375 | + } | |
| 376 | + | |
| 377 | + // Check widgets - with instant apply support | |
| 378 | + if let WidgetEvent::Changed = self.interval.on_click(x, y) { | |
| 379 | + self.check_dirty(); | |
| 380 | + // Interval change currently needs config save, no instant apply | |
| 381 | + return PanelAction::Redraw; | |
| 382 | + } | |
| 383 | + if let WidgetEvent::Changed = self.paused.on_click(x, y) { | |
| 384 | + self.check_dirty(); | |
| 385 | + self.pending_change = Some("paused"); | |
| 386 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 387 | + } | |
| 388 | + | |
| 389 | + // Control buttons | |
| 390 | + if let WidgetEvent::Clicked = self.prev_button.on_click(x, y) { | |
| 391 | + let _ = self.adapter.prev(None); | |
| 392 | + self.refresh_status(); | |
| 393 | + return PanelAction::Redraw; | |
| 394 | + } | |
| 395 | + if let WidgetEvent::Clicked = self.next_button.on_click(x, y) { | |
| 396 | + let _ = self.adapter.next(None); | |
| 397 | + self.refresh_status(); | |
| 398 | + return PanelAction::Redraw; | |
| 399 | + } | |
| 400 | + if let WidgetEvent::Clicked = self.pause_button.on_click(x, y) { | |
| 401 | + let _ = self.adapter.toggle(); | |
| 402 | + self.refresh_status(); | |
| 403 | + return PanelAction::Redraw; | |
| 404 | + } | |
| 405 | + | |
| 406 | + // Action buttons | |
| 407 | + if let WidgetEvent::Clicked = self.apply_button.on_click(x, y) { | |
| 408 | + return PanelAction::Apply; | |
| 409 | + } | |
| 410 | + if let WidgetEvent::Clicked = self.reset_button.on_click(x, y) { | |
| 411 | + return PanelAction::Reset; | |
| 412 | + } | |
| 413 | + if let WidgetEvent::Clicked = self.save_button.on_click(x, y) { | |
| 414 | + return PanelAction::Save; | |
| 415 | + } | |
| 416 | + | |
| 417 | + PanelAction::None | |
| 418 | + } | |
| 419 | + InputEvent::Scroll(se) => { | |
| 420 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 421 | + PanelAction::Redraw | |
| 422 | + } | |
| 423 | + _ => PanelAction::None, | |
| 424 | + } | |
| 425 | + } | |
| 426 | + | |
| 427 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 428 | + let mut changed = false; | |
| 429 | + changed |= self.current_section.on_mouse_move(x, y); | |
| 430 | + changed |= self.playlist_section.on_mouse_move(x, y); | |
| 431 | + changed |= self.slideshow_section.on_mouse_move(x, y); | |
| 432 | + changed |= self.controls_section.on_mouse_move(x, y); | |
| 433 | + | |
| 434 | + changed |= self.interval.on_mouse_move(x, y); | |
| 435 | + changed |= self.paused.on_mouse_move(x, y); | |
| 436 | + | |
| 437 | + changed |= self.prev_button.on_mouse_move(x, y); | |
| 438 | + changed |= self.pause_button.on_mouse_move(x, y); | |
| 439 | + changed |= self.next_button.on_mouse_move(x, y); | |
| 440 | + | |
| 441 | + changed |= self.apply_button.on_mouse_move(x, y); | |
| 442 | + changed |= self.reset_button.on_mouse_move(x, y); | |
| 443 | + changed |= self.save_button.on_mouse_move(x, y); | |
| 444 | + changed | |
| 445 | + } | |
| 446 | + | |
| 447 | + fn reset(&mut self) { | |
| 448 | + self.interval.value = self.original_values.interval; | |
| 449 | + self.paused.value = self.original_values.paused; | |
| 450 | + self.dirty = false; | |
| 451 | + } | |
| 452 | + | |
| 453 | + fn apply(&mut self) -> Result<()> { | |
| 454 | + if !self.adapter.is_connected() { | |
| 455 | + self.adapter.connect()?; | |
| 456 | + } | |
| 457 | + | |
| 458 | + // Toggle pause state if changed | |
| 459 | + if self.paused.value != self.original_values.paused { | |
| 460 | + self.adapter.toggle()?; | |
| 461 | + } | |
| 462 | + | |
| 463 | + // Note: interval changes require config file edit | |
| 464 | + // For now we track it but it won't take effect until config save | |
| 465 | + | |
| 466 | + // Update original values | |
| 467 | + self.original_values = GarbgValues { | |
| 468 | + interval: self.interval.value, | |
| 469 | + paused: self.paused.value, | |
| 470 | + }; | |
| 471 | + | |
| 472 | + self.dirty = false; | |
| 473 | + self.refresh_status(); | |
| 474 | + Ok(()) | |
| 475 | + } | |
| 476 | + | |
| 477 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 478 | + self.refresh_status(); | |
| 479 | + } | |
| 480 | + | |
| 481 | + fn has_config_file(&self) -> bool { | |
| 482 | + true | |
| 483 | + } | |
| 484 | + | |
| 485 | + fn save_to_config(&mut self) -> Result<()> { | |
| 486 | + // Load existing config or create new | |
| 487 | + let mut config = GarbgConfig::load().unwrap_or_default(); | |
| 488 | + | |
| 489 | + // Update slideshow settings | |
| 490 | + config.slideshow = Some(GarbgSlideshow { | |
| 491 | + enabled: !self.paused.value, | |
| 492 | + interval_secs: Some(self.interval.value as u64), | |
| 493 | + shuffle: config.slideshow.as_ref().and_then(|s| s.shuffle), | |
| 494 | + }); | |
| 495 | + | |
| 496 | + // Save config | |
| 497 | + config.save()?; | |
| 498 | + | |
| 499 | + // Trigger reload | |
| 500 | + crate::config::reload_component("garbg")?; | |
| 501 | + | |
| 502 | + Ok(()) | |
| 503 | + } | |
| 504 | + | |
| 505 | + fn set_instant_apply(&mut self, enabled: bool) { | |
| 506 | + self.instant_apply = enabled; | |
| 507 | + } | |
| 508 | + | |
| 509 | + fn instant_apply_enabled(&self) -> bool { | |
| 510 | + self.instant_apply | |
| 511 | + } | |
| 512 | + | |
| 513 | + fn apply_instant(&mut self) -> Result<()> { | |
| 514 | + if !self.adapter.is_connected() { | |
| 515 | + self.adapter.connect()?; | |
| 516 | + } | |
| 517 | + | |
| 518 | + // Apply only the pending change | |
| 519 | + if let Some(field) = self.pending_change.take() { | |
| 520 | + match field { | |
| 521 | + "paused" => { | |
| 522 | + // Toggle pause state | |
| 523 | + if self.paused.value != self.original_values.paused { | |
| 524 | + self.adapter.toggle()?; | |
| 525 | + self.original_values.paused = self.paused.value; | |
| 526 | + self.pause_button.label = if self.paused.value { | |
| 527 | + "Resume".to_string() | |
| 528 | + } else { | |
| 529 | + "Pause".to_string() | |
| 530 | + }; | |
| 531 | + } | |
| 532 | + } | |
| 533 | + _ => {} | |
| 534 | + } | |
| 535 | + self.check_dirty(); | |
| 536 | + self.refresh_status(); | |
| 537 | + } | |
| 538 | + | |
| 539 | + Ok(()) | |
| 540 | + } | |
| 541 | +} | |
gargears/src/panels/garclip.rsadded@@ -0,0 +1,279 @@ | ||
| 1 | +//! Configuration panel for garclip (clipboard manager) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GarclipAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, Toggle, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garclip configuration panel | |
| 11 | +pub struct GarclipPanel { | |
| 12 | + adapter: GarclipAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + actions_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + history_count_label: Label, | |
| 20 | + pinned_count_label: Label, | |
| 21 | + owns_clipboard_label: Label, | |
| 22 | + watching_primary_label: Label, | |
| 23 | + | |
| 24 | + // Action buttons | |
| 25 | + clear_button: Button, | |
| 26 | + clear_history_button: Button, | |
| 27 | + keep_pinned_toggle: Toggle, | |
| 28 | + refresh_button: Button, | |
| 29 | + | |
| 30 | + // State | |
| 31 | + scroll_offset: i32, | |
| 32 | +} | |
| 33 | + | |
| 34 | +impl GarclipPanel { | |
| 35 | + pub fn new(mut adapter: GarclipAdapter) -> Self { | |
| 36 | + let (history, pinned, owns, watching) = if adapter.is_connected() { | |
| 37 | + if let Ok(status) = adapter.status() { | |
| 38 | + ( | |
| 39 | + format!("{} item(s)", status.history_count), | |
| 40 | + format!("{} pinned", status.pinned_count), | |
| 41 | + if status.owns_clipboard { "Yes" } else { "No" }, | |
| 42 | + if status.watching_primary { "Yes" } else { "No" }, | |
| 43 | + ) | |
| 44 | + } else { | |
| 45 | + ("Unknown".into(), "Unknown".into(), "Unknown", "Unknown") | |
| 46 | + } | |
| 47 | + } else { | |
| 48 | + ("N/A".into(), "N/A".into(), "N/A", "N/A") | |
| 49 | + }; | |
| 50 | + | |
| 51 | + Self { | |
| 52 | + adapter, | |
| 53 | + status_section: Section::new("Status"), | |
| 54 | + actions_section: Section::new("Actions"), | |
| 55 | + history_count_label: Label::new("History Count", &history), | |
| 56 | + pinned_count_label: Label::new("Pinned Count", &pinned), | |
| 57 | + owns_clipboard_label: Label::new("Owns Clipboard", owns), | |
| 58 | + watching_primary_label: Label::new("Watching PRIMARY", watching), | |
| 59 | + clear_button: Button::new("Clear Clipboard"), | |
| 60 | + clear_history_button: Button::new("Clear History").primary(), | |
| 61 | + keep_pinned_toggle: Toggle::new("Keep Pinned Items", true), | |
| 62 | + refresh_button: Button::new("Refresh"), | |
| 63 | + scroll_offset: 0, | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + fn refresh_status(&mut self) { | |
| 68 | + if !self.adapter.is_connected() { | |
| 69 | + let _ = self.adapter.connect(); | |
| 70 | + } | |
| 71 | + | |
| 72 | + if let Ok(status) = self.adapter.status() { | |
| 73 | + self.history_count_label.text = format!("{} item(s)", status.history_count); | |
| 74 | + self.pinned_count_label.text = format!("{} pinned", status.pinned_count); | |
| 75 | + self.owns_clipboard_label.text = if status.owns_clipboard { "Yes" } else { "No" }.into(); | |
| 76 | + self.watching_primary_label.text = if status.watching_primary { "Yes" } else { "No" }.into(); | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 81 | + let padding = theme.padding as i32; | |
| 82 | + let row_height = 32; | |
| 83 | + let section_height = 32; | |
| 84 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 85 | + | |
| 86 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 87 | + | |
| 88 | + // Status section | |
| 89 | + self.status_section.bounds = | |
| 90 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 91 | + y += section_height; | |
| 92 | + | |
| 93 | + if self.status_section.expanded { | |
| 94 | + self.history_count_label.bounds = | |
| 95 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 96 | + y += row_height; | |
| 97 | + | |
| 98 | + self.pinned_count_label.bounds = | |
| 99 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 100 | + y += row_height; | |
| 101 | + | |
| 102 | + self.owns_clipboard_label.bounds = | |
| 103 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 104 | + y += row_height; | |
| 105 | + | |
| 106 | + self.watching_primary_label.bounds = | |
| 107 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 108 | + y += row_height; | |
| 109 | + } | |
| 110 | + | |
| 111 | + y += padding / 2; | |
| 112 | + | |
| 113 | + // Actions section | |
| 114 | + self.actions_section.bounds = | |
| 115 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 116 | + y += section_height; | |
| 117 | + | |
| 118 | + if self.actions_section.expanded { | |
| 119 | + self.clear_button.bounds = Rect::new( | |
| 120 | + bounds.x + padding + 16, | |
| 121 | + y, | |
| 122 | + 120, | |
| 123 | + 32, | |
| 124 | + ); | |
| 125 | + | |
| 126 | + self.refresh_button.bounds = Rect::new( | |
| 127 | + bounds.x + padding + 16 + 128, | |
| 128 | + y, | |
| 129 | + 80, | |
| 130 | + 32, | |
| 131 | + ); | |
| 132 | + | |
| 133 | + y += 40; | |
| 134 | + | |
| 135 | + self.keep_pinned_toggle.bounds = | |
| 136 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 137 | + y += row_height + 8; | |
| 138 | + | |
| 139 | + self.clear_history_button.bounds = Rect::new( | |
| 140 | + bounds.x + padding + 16, | |
| 141 | + y, | |
| 142 | + 120, | |
| 143 | + 32, | |
| 144 | + ); | |
| 145 | + } | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +impl Panel for GarclipPanel { | |
| 150 | + fn name(&self) -> &str { | |
| 151 | + "garclip" | |
| 152 | + } | |
| 153 | + | |
| 154 | + fn description(&self) -> &str { | |
| 155 | + "Clipboard manager" | |
| 156 | + } | |
| 157 | + | |
| 158 | + fn is_dirty(&self) -> bool { | |
| 159 | + false | |
| 160 | + } | |
| 161 | + | |
| 162 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 163 | + self.layout_widgets(bounds, theme); | |
| 164 | + | |
| 165 | + // Render sections | |
| 166 | + self.status_section.render(renderer, theme)?; | |
| 167 | + if self.status_section.expanded { | |
| 168 | + self.history_count_label.render(renderer, theme)?; | |
| 169 | + self.pinned_count_label.render(renderer, theme)?; | |
| 170 | + self.owns_clipboard_label.render(renderer, theme)?; | |
| 171 | + self.watching_primary_label.render(renderer, theme)?; | |
| 172 | + } | |
| 173 | + | |
| 174 | + self.actions_section.render(renderer, theme)?; | |
| 175 | + if self.actions_section.expanded { | |
| 176 | + self.clear_button.render(renderer, theme)?; | |
| 177 | + self.refresh_button.render(renderer, theme)?; | |
| 178 | + self.keep_pinned_toggle.render(renderer, theme)?; | |
| 179 | + self.clear_history_button.render(renderer, theme)?; | |
| 180 | + } | |
| 181 | + | |
| 182 | + // Connection status | |
| 183 | + let status_color = if self.adapter.is_connected() { | |
| 184 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 185 | + } else { | |
| 186 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 187 | + }; | |
| 188 | + | |
| 189 | + let status_style = TextStyle::new() | |
| 190 | + .font_family(&theme.font_family) | |
| 191 | + .font_size(theme.font_size * 0.85) | |
| 192 | + .color(status_color); | |
| 193 | + | |
| 194 | + let status_text = if self.adapter.is_connected() { | |
| 195 | + "Connected" | |
| 196 | + } else { | |
| 197 | + "Not running" | |
| 198 | + }; | |
| 199 | + | |
| 200 | + renderer.text( | |
| 201 | + status_text, | |
| 202 | + (bounds.x + theme.padding as i32) as f64, | |
| 203 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 204 | + &status_style, | |
| 205 | + )?; | |
| 206 | + | |
| 207 | + Ok(()) | |
| 208 | + } | |
| 209 | + | |
| 210 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 211 | + match event { | |
| 212 | + InputEvent::MousePress(me) => { | |
| 213 | + let x = me.position.x; | |
| 214 | + let y = me.position.y; | |
| 215 | + | |
| 216 | + // Check sections | |
| 217 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 218 | + return PanelAction::Redraw; | |
| 219 | + } | |
| 220 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 221 | + return PanelAction::Redraw; | |
| 222 | + } | |
| 223 | + | |
| 224 | + // Toggle | |
| 225 | + if let WidgetEvent::Changed = self.keep_pinned_toggle.on_click(x, y) { | |
| 226 | + return PanelAction::Redraw; | |
| 227 | + } | |
| 228 | + | |
| 229 | + // Action buttons | |
| 230 | + if let WidgetEvent::Clicked = self.clear_button.on_click(x, y) { | |
| 231 | + if self.adapter.is_connected() { | |
| 232 | + let _ = self.adapter.clear(); | |
| 233 | + self.refresh_status(); | |
| 234 | + } | |
| 235 | + return PanelAction::Redraw; | |
| 236 | + } | |
| 237 | + if let WidgetEvent::Clicked = self.clear_history_button.on_click(x, y) { | |
| 238 | + if self.adapter.is_connected() { | |
| 239 | + let _ = self.adapter.clear_history(self.keep_pinned_toggle.value); | |
| 240 | + self.refresh_status(); | |
| 241 | + } | |
| 242 | + return PanelAction::Redraw; | |
| 243 | + } | |
| 244 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 245 | + self.refresh_status(); | |
| 246 | + return PanelAction::Redraw; | |
| 247 | + } | |
| 248 | + | |
| 249 | + PanelAction::None | |
| 250 | + } | |
| 251 | + InputEvent::Scroll(se) => { | |
| 252 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 253 | + PanelAction::Redraw | |
| 254 | + } | |
| 255 | + _ => PanelAction::None, | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 260 | + let mut changed = false; | |
| 261 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 262 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 263 | + changed |= self.keep_pinned_toggle.on_mouse_move(x, y); | |
| 264 | + changed |= self.clear_button.on_mouse_move(x, y); | |
| 265 | + changed |= self.clear_history_button.on_mouse_move(x, y); | |
| 266 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 267 | + changed | |
| 268 | + } | |
| 269 | + | |
| 270 | + fn reset(&mut self) {} | |
| 271 | + | |
| 272 | + fn apply(&mut self) -> Result<()> { | |
| 273 | + Ok(()) | |
| 274 | + } | |
| 275 | + | |
| 276 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 277 | + self.refresh_status(); | |
| 278 | + } | |
| 279 | +} | |
gargears/src/panels/garfield.rsadded@@ -0,0 +1,260 @@ | ||
| 1 | +//! Configuration panel for garfield (file explorer) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GarfieldAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, TextInput, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Key, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garfield configuration panel | |
| 11 | +pub struct GarfieldPanel { | |
| 12 | + adapter: GarfieldAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + navigation_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + running_label: Label, | |
| 20 | + current_dir_label: Label, | |
| 21 | + | |
| 22 | + // Navigation | |
| 23 | + open_path: TextInput, | |
| 24 | + | |
| 25 | + // Action buttons | |
| 26 | + open_button: Button, | |
| 27 | + refresh_button: Button, | |
| 28 | + | |
| 29 | + // State | |
| 30 | + scroll_offset: i32, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl GarfieldPanel { | |
| 34 | + pub fn new(mut adapter: GarfieldAdapter) -> Self { | |
| 35 | + let (running, current_dir): (String, String) = if adapter.is_connected() { | |
| 36 | + if let Ok(status) = adapter.status() { | |
| 37 | + let running = if status.running { "Running" } else { "Not running" }; | |
| 38 | + let dir = status.current_dir.unwrap_or_else(|| "Unknown".to_string()); | |
| 39 | + (running.to_string(), dir) | |
| 40 | + } else { | |
| 41 | + ("Unknown".to_string(), "Unknown".to_string()) | |
| 42 | + } | |
| 43 | + } else { | |
| 44 | + ("Not connected".to_string(), "N/A".to_string()) | |
| 45 | + }; | |
| 46 | + | |
| 47 | + Self { | |
| 48 | + adapter, | |
| 49 | + status_section: Section::new("Status"), | |
| 50 | + navigation_section: Section::new("Navigation"), | |
| 51 | + running_label: Label::new("Status", &running), | |
| 52 | + current_dir_label: Label::new("Current Directory", ¤t_dir), | |
| 53 | + open_path: TextInput::new("Path", "").with_placeholder("/home/user"), | |
| 54 | + open_button: Button::new("Open").primary(), | |
| 55 | + refresh_button: Button::new("Refresh"), | |
| 56 | + scroll_offset: 0, | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + fn refresh_status(&mut self) { | |
| 61 | + if !self.adapter.is_connected() { | |
| 62 | + let _ = self.adapter.connect(); | |
| 63 | + } | |
| 64 | + | |
| 65 | + if let Ok(status) = self.adapter.status() { | |
| 66 | + let running = if status.running { "Running" } else { "Not running" }; | |
| 67 | + self.running_label.text = running.into(); | |
| 68 | + self.current_dir_label.text = status.current_dir.unwrap_or_else(|| "Unknown".into()); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 73 | + let padding = theme.padding as i32; | |
| 74 | + let row_height = 36; | |
| 75 | + let section_height = 32; | |
| 76 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 77 | + | |
| 78 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 79 | + | |
| 80 | + // Status section | |
| 81 | + self.status_section.bounds = | |
| 82 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 83 | + y += section_height; | |
| 84 | + | |
| 85 | + if self.status_section.expanded { | |
| 86 | + self.running_label.bounds = | |
| 87 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 88 | + y += row_height; | |
| 89 | + | |
| 90 | + self.current_dir_label.bounds = | |
| 91 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 92 | + y += row_height; | |
| 93 | + } | |
| 94 | + | |
| 95 | + y += padding / 2; | |
| 96 | + | |
| 97 | + // Navigation section | |
| 98 | + self.navigation_section.bounds = | |
| 99 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 100 | + y += section_height; | |
| 101 | + | |
| 102 | + if self.navigation_section.expanded { | |
| 103 | + self.open_path.bounds = | |
| 104 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 105 | + y += row_height + 8; | |
| 106 | + | |
| 107 | + self.open_button.bounds = Rect::new( | |
| 108 | + bounds.x + padding + 16, | |
| 109 | + y, | |
| 110 | + 80, | |
| 111 | + 32, | |
| 112 | + ); | |
| 113 | + | |
| 114 | + self.refresh_button.bounds = Rect::new( | |
| 115 | + bounds.x + padding + 16 + 88, | |
| 116 | + y, | |
| 117 | + 80, | |
| 118 | + 32, | |
| 119 | + ); | |
| 120 | + } | |
| 121 | + } | |
| 122 | +} | |
| 123 | + | |
| 124 | +impl Panel for GarfieldPanel { | |
| 125 | + fn name(&self) -> &str { | |
| 126 | + "garfield" | |
| 127 | + } | |
| 128 | + | |
| 129 | + fn description(&self) -> &str { | |
| 130 | + "File explorer" | |
| 131 | + } | |
| 132 | + | |
| 133 | + fn is_dirty(&self) -> bool { | |
| 134 | + false | |
| 135 | + } | |
| 136 | + | |
| 137 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 138 | + self.layout_widgets(bounds, theme); | |
| 139 | + | |
| 140 | + // Render sections | |
| 141 | + self.status_section.render(renderer, theme)?; | |
| 142 | + if self.status_section.expanded { | |
| 143 | + self.running_label.render(renderer, theme)?; | |
| 144 | + self.current_dir_label.render(renderer, theme)?; | |
| 145 | + } | |
| 146 | + | |
| 147 | + self.navigation_section.render(renderer, theme)?; | |
| 148 | + if self.navigation_section.expanded { | |
| 149 | + self.open_path.render(renderer, theme)?; | |
| 150 | + self.open_button.render(renderer, theme)?; | |
| 151 | + self.refresh_button.render(renderer, theme)?; | |
| 152 | + } | |
| 153 | + | |
| 154 | + // Connection status | |
| 155 | + let status_color = if self.adapter.is_connected() { | |
| 156 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 157 | + } else { | |
| 158 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 159 | + }; | |
| 160 | + | |
| 161 | + let status_style = TextStyle::new() | |
| 162 | + .font_family(&theme.font_family) | |
| 163 | + .font_size(theme.font_size * 0.85) | |
| 164 | + .color(status_color); | |
| 165 | + | |
| 166 | + let status_text = if self.adapter.is_connected() { | |
| 167 | + "Connected" | |
| 168 | + } else { | |
| 169 | + "Not running" | |
| 170 | + }; | |
| 171 | + | |
| 172 | + renderer.text( | |
| 173 | + status_text, | |
| 174 | + (bounds.x + theme.padding as i32) as f64, | |
| 175 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 176 | + &status_style, | |
| 177 | + )?; | |
| 178 | + | |
| 179 | + Ok(()) | |
| 180 | + } | |
| 181 | + | |
| 182 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 183 | + match event { | |
| 184 | + InputEvent::Key(ke) if ke.pressed => { | |
| 185 | + // Handle text input | |
| 186 | + if let WidgetEvent::Changed = self.open_path.on_key(&ke.key) { | |
| 187 | + return PanelAction::Redraw; | |
| 188 | + } | |
| 189 | + | |
| 190 | + // Enter to open path | |
| 191 | + if matches!(ke.key, Key::Return) && !self.open_path.value.is_empty() { | |
| 192 | + if self.adapter.is_connected() { | |
| 193 | + let _ = self.adapter.open(&self.open_path.value); | |
| 194 | + self.refresh_status(); | |
| 195 | + } | |
| 196 | + return PanelAction::Redraw; | |
| 197 | + } | |
| 198 | + | |
| 199 | + PanelAction::None | |
| 200 | + } | |
| 201 | + InputEvent::MousePress(me) => { | |
| 202 | + let x = me.position.x; | |
| 203 | + let y = me.position.y; | |
| 204 | + | |
| 205 | + // Check sections | |
| 206 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 207 | + return PanelAction::Redraw; | |
| 208 | + } | |
| 209 | + if let WidgetEvent::Changed = self.navigation_section.on_click(x, y) { | |
| 210 | + return PanelAction::Redraw; | |
| 211 | + } | |
| 212 | + | |
| 213 | + // Action buttons | |
| 214 | + if let WidgetEvent::Clicked = self.open_button.on_click(x, y) { | |
| 215 | + if self.adapter.is_connected() && !self.open_path.value.is_empty() { | |
| 216 | + let _ = self.adapter.open(&self.open_path.value); | |
| 217 | + self.refresh_status(); | |
| 218 | + } | |
| 219 | + return PanelAction::Redraw; | |
| 220 | + } | |
| 221 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 222 | + self.refresh_status(); | |
| 223 | + return PanelAction::Redraw; | |
| 224 | + } | |
| 225 | + | |
| 226 | + // Text input - check if focus changed | |
| 227 | + if let WidgetEvent::Changed = self.open_path.on_click(x, y) { | |
| 228 | + return PanelAction::Redraw; | |
| 229 | + } | |
| 230 | + | |
| 231 | + PanelAction::None | |
| 232 | + } | |
| 233 | + InputEvent::Scroll(se) => { | |
| 234 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 235 | + PanelAction::Redraw | |
| 236 | + } | |
| 237 | + _ => PanelAction::None, | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 242 | + let mut changed = false; | |
| 243 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 244 | + changed |= self.navigation_section.on_mouse_move(x, y); | |
| 245 | + changed |= self.open_path.on_mouse_move(x, y); | |
| 246 | + changed |= self.open_button.on_mouse_move(x, y); | |
| 247 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 248 | + changed | |
| 249 | + } | |
| 250 | + | |
| 251 | + fn reset(&mut self) {} | |
| 252 | + | |
| 253 | + fn apply(&mut self) -> Result<()> { | |
| 254 | + Ok(()) | |
| 255 | + } | |
| 256 | + | |
| 257 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 258 | + self.refresh_status(); | |
| 259 | + } | |
| 260 | +} | |
gargears/src/panels/garlaunch.rsadded@@ -0,0 +1,247 @@ | ||
| 1 | +//! Configuration panel for garlaunch (application launcher) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GarlaunchAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garlaunch configuration panel | |
| 11 | +pub struct GarlaunchPanel { | |
| 12 | + adapter: GarlaunchAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + actions_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + daemon_running_label: Label, | |
| 20 | + | |
| 21 | + // Action buttons | |
| 22 | + show_button: Button, | |
| 23 | + toggle_button: Button, | |
| 24 | + refresh_button: Button, | |
| 25 | + | |
| 26 | + // State | |
| 27 | + scroll_offset: i32, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl GarlaunchPanel { | |
| 31 | + pub fn new(mut adapter: GarlaunchAdapter) -> Self { | |
| 32 | + let daemon_running = if adapter.is_connected() { | |
| 33 | + if let Ok(status) = adapter.status() { | |
| 34 | + if status.daemon_running { "Running" } else { "Not running" } | |
| 35 | + } else { | |
| 36 | + "Unknown" | |
| 37 | + } | |
| 38 | + } else { | |
| 39 | + "Not connected" | |
| 40 | + }; | |
| 41 | + | |
| 42 | + Self { | |
| 43 | + adapter, | |
| 44 | + status_section: Section::new("Status"), | |
| 45 | + actions_section: Section::new("Actions"), | |
| 46 | + daemon_running_label: Label::new("Daemon Status", daemon_running), | |
| 47 | + show_button: Button::new("Show Launcher").primary(), | |
| 48 | + toggle_button: Button::new("Toggle"), | |
| 49 | + refresh_button: Button::new("Refresh"), | |
| 50 | + scroll_offset: 0, | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + fn refresh_status(&mut self) { | |
| 55 | + if !self.adapter.is_connected() { | |
| 56 | + let _ = self.adapter.connect(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + if let Ok(status) = self.adapter.status() { | |
| 60 | + self.daemon_running_label.text = | |
| 61 | + if status.daemon_running { "Running" } else { "Not running" }.into(); | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 66 | + let padding = theme.padding as i32; | |
| 67 | + let row_height = 32; | |
| 68 | + let section_height = 32; | |
| 69 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 70 | + | |
| 71 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 72 | + | |
| 73 | + // Status section | |
| 74 | + self.status_section.bounds = | |
| 75 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 76 | + y += section_height; | |
| 77 | + | |
| 78 | + if self.status_section.expanded { | |
| 79 | + self.daemon_running_label.bounds = | |
| 80 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 81 | + y += row_height; | |
| 82 | + } | |
| 83 | + | |
| 84 | + y += padding / 2; | |
| 85 | + | |
| 86 | + // Actions section | |
| 87 | + self.actions_section.bounds = | |
| 88 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 89 | + y += section_height; | |
| 90 | + | |
| 91 | + if self.actions_section.expanded { | |
| 92 | + self.show_button.bounds = Rect::new( | |
| 93 | + bounds.x + padding + 16, | |
| 94 | + y, | |
| 95 | + 120, | |
| 96 | + 32, | |
| 97 | + ); | |
| 98 | + | |
| 99 | + self.toggle_button.bounds = Rect::new( | |
| 100 | + bounds.x + padding + 16 + 128, | |
| 101 | + y, | |
| 102 | + 80, | |
| 103 | + 32, | |
| 104 | + ); | |
| 105 | + | |
| 106 | + self.refresh_button.bounds = Rect::new( | |
| 107 | + bounds.x + padding + 16 + 128 + 88, | |
| 108 | + y, | |
| 109 | + 80, | |
| 110 | + 32, | |
| 111 | + ); | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +impl Panel for GarlaunchPanel { | |
| 117 | + fn name(&self) -> &str { | |
| 118 | + "garlaunch" | |
| 119 | + } | |
| 120 | + | |
| 121 | + fn description(&self) -> &str { | |
| 122 | + "Application launcher" | |
| 123 | + } | |
| 124 | + | |
| 125 | + fn is_dirty(&self) -> bool { | |
| 126 | + false | |
| 127 | + } | |
| 128 | + | |
| 129 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 130 | + self.layout_widgets(bounds, theme); | |
| 131 | + | |
| 132 | + // Render sections | |
| 133 | + self.status_section.render(renderer, theme)?; | |
| 134 | + if self.status_section.expanded { | |
| 135 | + self.daemon_running_label.render(renderer, theme)?; | |
| 136 | + } | |
| 137 | + | |
| 138 | + self.actions_section.render(renderer, theme)?; | |
| 139 | + if self.actions_section.expanded { | |
| 140 | + self.show_button.render(renderer, theme)?; | |
| 141 | + self.toggle_button.render(renderer, theme)?; | |
| 142 | + self.refresh_button.render(renderer, theme)?; | |
| 143 | + } | |
| 144 | + | |
| 145 | + // Hint text | |
| 146 | + let hint_style = TextStyle::new() | |
| 147 | + .font_family(&theme.font_family) | |
| 148 | + .font_size(theme.font_size * 0.85) | |
| 149 | + .color(theme.item_description); | |
| 150 | + | |
| 151 | + renderer.text( | |
| 152 | + "Use keybind or CLI to show launcher", | |
| 153 | + (bounds.x + theme.padding as i32) as f64, | |
| 154 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 40) as f64, | |
| 155 | + &hint_style, | |
| 156 | + )?; | |
| 157 | + | |
| 158 | + // Connection status | |
| 159 | + let status_color = if self.adapter.is_connected() { | |
| 160 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 161 | + } else { | |
| 162 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 163 | + }; | |
| 164 | + | |
| 165 | + let status_style = TextStyle::new() | |
| 166 | + .font_family(&theme.font_family) | |
| 167 | + .font_size(theme.font_size * 0.85) | |
| 168 | + .color(status_color); | |
| 169 | + | |
| 170 | + let status_text = if self.adapter.is_connected() { | |
| 171 | + "Connected" | |
| 172 | + } else { | |
| 173 | + "Not running" | |
| 174 | + }; | |
| 175 | + | |
| 176 | + renderer.text( | |
| 177 | + status_text, | |
| 178 | + (bounds.x + theme.padding as i32) as f64, | |
| 179 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 180 | + &status_style, | |
| 181 | + )?; | |
| 182 | + | |
| 183 | + Ok(()) | |
| 184 | + } | |
| 185 | + | |
| 186 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 187 | + match event { | |
| 188 | + InputEvent::MousePress(me) => { | |
| 189 | + let x = me.position.x; | |
| 190 | + let y = me.position.y; | |
| 191 | + | |
| 192 | + // Check sections | |
| 193 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 194 | + return PanelAction::Redraw; | |
| 195 | + } | |
| 196 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 197 | + return PanelAction::Redraw; | |
| 198 | + } | |
| 199 | + | |
| 200 | + // Action buttons | |
| 201 | + if let WidgetEvent::Clicked = self.show_button.on_click(x, y) { | |
| 202 | + if self.adapter.is_connected() { | |
| 203 | + let _ = self.adapter.show(None); | |
| 204 | + } | |
| 205 | + return PanelAction::Redraw; | |
| 206 | + } | |
| 207 | + if let WidgetEvent::Clicked = self.toggle_button.on_click(x, y) { | |
| 208 | + if self.adapter.is_connected() { | |
| 209 | + let _ = self.adapter.toggle(None); | |
| 210 | + } | |
| 211 | + return PanelAction::Redraw; | |
| 212 | + } | |
| 213 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 214 | + self.refresh_status(); | |
| 215 | + return PanelAction::Redraw; | |
| 216 | + } | |
| 217 | + | |
| 218 | + PanelAction::None | |
| 219 | + } | |
| 220 | + InputEvent::Scroll(se) => { | |
| 221 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 222 | + PanelAction::Redraw | |
| 223 | + } | |
| 224 | + _ => PanelAction::None, | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 229 | + let mut changed = false; | |
| 230 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 231 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 232 | + changed |= self.show_button.on_mouse_move(x, y); | |
| 233 | + changed |= self.toggle_button.on_mouse_move(x, y); | |
| 234 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 235 | + changed | |
| 236 | + } | |
| 237 | + | |
| 238 | + fn reset(&mut self) {} | |
| 239 | + | |
| 240 | + fn apply(&mut self) -> Result<()> { | |
| 241 | + Ok(()) | |
| 242 | + } | |
| 243 | + | |
| 244 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 245 | + self.refresh_status(); | |
| 246 | + } | |
| 247 | +} | |
gargears/src/panels/garlock.rsadded@@ -0,0 +1,326 @@ | ||
| 1 | +//! Configuration panel for garlock (screen locker) | |
| 2 | + | |
| 3 | +use crate::config::GarlockConfig; | |
| 4 | +use crate::ipc::adapters::GarlockAdapter; | |
| 5 | +use crate::ui::widgets::{Button, Label, NumberInput, Section, WidgetEvent}; | |
| 6 | +use crate::ui::{Panel, PanelAction}; | |
| 7 | +use anyhow::Result; | |
| 8 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 9 | +use gartk_render::{Renderer, TextStyle}; | |
| 10 | + | |
| 11 | +/// Garlock configuration panel | |
| 12 | +pub struct GarlockPanel { | |
| 13 | + adapter: GarlockAdapter, | |
| 14 | + | |
| 15 | + // Sections | |
| 16 | + status_section: Section, | |
| 17 | + settings_section: Section, | |
| 18 | + actions_section: Section, | |
| 19 | + | |
| 20 | + // Status display | |
| 21 | + locked_label: Label, | |
| 22 | + | |
| 23 | + // Settings | |
| 24 | + idle_timeout: NumberInput, | |
| 25 | + | |
| 26 | + // Action buttons | |
| 27 | + lock_button: Button, | |
| 28 | + refresh_button: Button, | |
| 29 | + save_button: Button, | |
| 30 | + | |
| 31 | + // State | |
| 32 | + original_timeout: i32, | |
| 33 | + dirty: bool, | |
| 34 | + scroll_offset: i32, | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl GarlockPanel { | |
| 38 | + pub fn new(mut adapter: GarlockAdapter) -> Self { | |
| 39 | + let (locked, timeout): (String, i32) = if adapter.is_connected() { | |
| 40 | + if let Ok(status) = adapter.status() { | |
| 41 | + let locked_text = if status.locked { "Locked" } else { "Unlocked" }; | |
| 42 | + let timeout = status.idle_timeout_secs.unwrap_or(300) as i32; | |
| 43 | + (locked_text.to_string(), timeout) | |
| 44 | + } else { | |
| 45 | + ("Unknown".to_string(), 300) | |
| 46 | + } | |
| 47 | + } else { | |
| 48 | + ("Not connected".to_string(), 300) | |
| 49 | + }; | |
| 50 | + | |
| 51 | + Self { | |
| 52 | + adapter, | |
| 53 | + status_section: Section::new("Status"), | |
| 54 | + settings_section: Section::new("Settings"), | |
| 55 | + actions_section: Section::new("Actions"), | |
| 56 | + locked_label: Label::new("Lock Status", &locked), | |
| 57 | + idle_timeout: NumberInput::new("Idle Timeout (seconds)", timeout) | |
| 58 | + .with_range(30, 3600) | |
| 59 | + .with_step(30), | |
| 60 | + lock_button: Button::new("Lock Now").primary(), | |
| 61 | + refresh_button: Button::new("Refresh"), | |
| 62 | + save_button: Button::new("Save"), | |
| 63 | + original_timeout: timeout, | |
| 64 | + dirty: false, | |
| 65 | + scroll_offset: 0, | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + fn refresh_status(&mut self) { | |
| 70 | + if !self.adapter.is_connected() { | |
| 71 | + let _ = self.adapter.connect(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + if let Ok(status) = self.adapter.status() { | |
| 75 | + let locked_text = if status.locked { "Locked" } else { "Unlocked" }; | |
| 76 | + self.locked_label.text = locked_text.into(); | |
| 77 | + | |
| 78 | + if let Some(timeout) = status.idle_timeout_secs { | |
| 79 | + self.idle_timeout.value = timeout as i32; | |
| 80 | + self.original_timeout = timeout as i32; | |
| 81 | + } | |
| 82 | + self.dirty = false; | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + fn check_dirty(&mut self) { | |
| 87 | + self.dirty = self.idle_timeout.value != self.original_timeout; | |
| 88 | + } | |
| 89 | + | |
| 90 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 91 | + let padding = theme.padding as i32; | |
| 92 | + let row_height = 36; | |
| 93 | + let section_height = 32; | |
| 94 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 95 | + | |
| 96 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 97 | + | |
| 98 | + // Status section | |
| 99 | + self.status_section.bounds = | |
| 100 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 101 | + y += section_height; | |
| 102 | + | |
| 103 | + if self.status_section.expanded { | |
| 104 | + self.locked_label.bounds = | |
| 105 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 106 | + y += row_height; | |
| 107 | + } | |
| 108 | + | |
| 109 | + y += padding / 2; | |
| 110 | + | |
| 111 | + // Settings section | |
| 112 | + self.settings_section.bounds = | |
| 113 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 114 | + y += section_height; | |
| 115 | + | |
| 116 | + if self.settings_section.expanded { | |
| 117 | + self.idle_timeout.bounds = | |
| 118 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 119 | + y += row_height; | |
| 120 | + } | |
| 121 | + | |
| 122 | + y += padding / 2; | |
| 123 | + | |
| 124 | + // Actions section | |
| 125 | + self.actions_section.bounds = | |
| 126 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 127 | + y += section_height; | |
| 128 | + | |
| 129 | + if self.actions_section.expanded { | |
| 130 | + self.lock_button.bounds = Rect::new( | |
| 131 | + bounds.x + padding + 16, | |
| 132 | + y, | |
| 133 | + 100, | |
| 134 | + 32, | |
| 135 | + ); | |
| 136 | + | |
| 137 | + self.refresh_button.bounds = Rect::new( | |
| 138 | + bounds.x + padding + 16 + 108, | |
| 139 | + y, | |
| 140 | + 100, | |
| 141 | + 32, | |
| 142 | + ); | |
| 143 | + | |
| 144 | + self.save_button.bounds = Rect::new( | |
| 145 | + bounds.x + padding + 16 + 216, | |
| 146 | + y, | |
| 147 | + 80, | |
| 148 | + 32, | |
| 149 | + ); | |
| 150 | + } | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +impl Panel for GarlockPanel { | |
| 155 | + fn name(&self) -> &str { | |
| 156 | + "garlock" | |
| 157 | + } | |
| 158 | + | |
| 159 | + fn description(&self) -> &str { | |
| 160 | + "Screen locker" | |
| 161 | + } | |
| 162 | + | |
| 163 | + fn is_dirty(&self) -> bool { | |
| 164 | + self.dirty | |
| 165 | + } | |
| 166 | + | |
| 167 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 168 | + self.layout_widgets(bounds, theme); | |
| 169 | + | |
| 170 | + // Render sections | |
| 171 | + self.status_section.render(renderer, theme)?; | |
| 172 | + if self.status_section.expanded { | |
| 173 | + self.locked_label.render(renderer, theme)?; | |
| 174 | + } | |
| 175 | + | |
| 176 | + self.settings_section.render(renderer, theme)?; | |
| 177 | + if self.settings_section.expanded { | |
| 178 | + self.idle_timeout.render(renderer, theme)?; | |
| 179 | + } | |
| 180 | + | |
| 181 | + self.actions_section.render(renderer, theme)?; | |
| 182 | + if self.actions_section.expanded { | |
| 183 | + self.lock_button.render(renderer, theme)?; | |
| 184 | + self.refresh_button.render(renderer, theme)?; | |
| 185 | + self.save_button.render(renderer, theme)?; | |
| 186 | + } | |
| 187 | + | |
| 188 | + // Connection status | |
| 189 | + let status_color = if self.adapter.is_connected() { | |
| 190 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 191 | + } else { | |
| 192 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 193 | + }; | |
| 194 | + | |
| 195 | + let status_style = TextStyle::new() | |
| 196 | + .font_family(&theme.font_family) | |
| 197 | + .font_size(theme.font_size * 0.85) | |
| 198 | + .color(status_color); | |
| 199 | + | |
| 200 | + let status_text = if self.adapter.is_connected() { | |
| 201 | + "Connected" | |
| 202 | + } else { | |
| 203 | + "Not running" | |
| 204 | + }; | |
| 205 | + | |
| 206 | + renderer.text( | |
| 207 | + status_text, | |
| 208 | + (bounds.x + theme.padding as i32) as f64, | |
| 209 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 210 | + &status_style, | |
| 211 | + )?; | |
| 212 | + | |
| 213 | + // Dirty indicator | |
| 214 | + if self.dirty { | |
| 215 | + let indicator_style = TextStyle::new() | |
| 216 | + .font_family(&theme.font_family) | |
| 217 | + .font_size(theme.font_size * 0.75) | |
| 218 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 219 | + | |
| 220 | + renderer.text( | |
| 221 | + "Unsaved changes", | |
| 222 | + (bounds.x + bounds.width as i32 - theme.padding as i32 - 100) as f64, | |
| 223 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 224 | + &indicator_style, | |
| 225 | + )?; | |
| 226 | + } | |
| 227 | + | |
| 228 | + Ok(()) | |
| 229 | + } | |
| 230 | + | |
| 231 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 232 | + match event { | |
| 233 | + InputEvent::MousePress(me) => { | |
| 234 | + let x = me.position.x; | |
| 235 | + let y = me.position.y; | |
| 236 | + | |
| 237 | + // Check sections | |
| 238 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 239 | + return PanelAction::Redraw; | |
| 240 | + } | |
| 241 | + if let WidgetEvent::Changed = self.settings_section.on_click(x, y) { | |
| 242 | + return PanelAction::Redraw; | |
| 243 | + } | |
| 244 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 245 | + return PanelAction::Redraw; | |
| 246 | + } | |
| 247 | + | |
| 248 | + // Settings | |
| 249 | + if let WidgetEvent::Changed = self.idle_timeout.on_click(x, y) { | |
| 250 | + self.check_dirty(); | |
| 251 | + return PanelAction::Redraw; | |
| 252 | + } | |
| 253 | + | |
| 254 | + // Action buttons | |
| 255 | + if let WidgetEvent::Clicked = self.lock_button.on_click(x, y) { | |
| 256 | + if self.adapter.is_connected() { | |
| 257 | + let _ = self.adapter.lock(); | |
| 258 | + } | |
| 259 | + return PanelAction::Redraw; | |
| 260 | + } | |
| 261 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 262 | + self.refresh_status(); | |
| 263 | + return PanelAction::Redraw; | |
| 264 | + } | |
| 265 | + if let WidgetEvent::Clicked = self.save_button.on_click(x, y) { | |
| 266 | + return PanelAction::Save; | |
| 267 | + } | |
| 268 | + | |
| 269 | + PanelAction::None | |
| 270 | + } | |
| 271 | + InputEvent::Scroll(se) => { | |
| 272 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 273 | + PanelAction::Redraw | |
| 274 | + } | |
| 275 | + _ => PanelAction::None, | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 280 | + let mut changed = false; | |
| 281 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 282 | + changed |= self.settings_section.on_mouse_move(x, y); | |
| 283 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 284 | + changed |= self.idle_timeout.on_mouse_move(x, y); | |
| 285 | + changed |= self.lock_button.on_mouse_move(x, y); | |
| 286 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 287 | + changed |= self.save_button.on_mouse_move(x, y); | |
| 288 | + changed | |
| 289 | + } | |
| 290 | + | |
| 291 | + fn reset(&mut self) { | |
| 292 | + self.idle_timeout.value = self.original_timeout; | |
| 293 | + self.dirty = false; | |
| 294 | + } | |
| 295 | + | |
| 296 | + fn apply(&mut self) -> Result<()> { | |
| 297 | + // Note: Timeout changes would need config file edit | |
| 298 | + // For now, just update original values | |
| 299 | + self.original_timeout = self.idle_timeout.value; | |
| 300 | + self.dirty = false; | |
| 301 | + Ok(()) | |
| 302 | + } | |
| 303 | + | |
| 304 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 305 | + self.refresh_status(); | |
| 306 | + } | |
| 307 | + | |
| 308 | + fn has_config_file(&self) -> bool { | |
| 309 | + true | |
| 310 | + } | |
| 311 | + | |
| 312 | + fn save_to_config(&mut self) -> Result<()> { | |
| 313 | + let mut config = GarlockConfig::load().unwrap_or_default(); | |
| 314 | + | |
| 315 | + // Update idle timeout | |
| 316 | + config.idle_timeout_secs = Some(self.idle_timeout.value as u64); | |
| 317 | + | |
| 318 | + // Save config | |
| 319 | + config.save()?; | |
| 320 | + | |
| 321 | + // Trigger reload (garlock reads on next lock) | |
| 322 | + crate::config::reload_component("garlock")?; | |
| 323 | + | |
| 324 | + Ok(()) | |
| 325 | + } | |
| 326 | +} | |
gargears/src/panels/garnotify.rsadded@@ -0,0 +1,342 @@ | ||
| 1 | +//! Configuration panel for garnotify (notification daemon) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GarnotifyAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, Toggle, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garnotify configuration panel | |
| 11 | +pub struct GarnotifyPanel { | |
| 12 | + adapter: GarnotifyAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + actions_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + paused_toggle: Toggle, | |
| 20 | + pending_count_label: Label, | |
| 21 | + history_count_label: Label, | |
| 22 | + | |
| 23 | + // Action buttons | |
| 24 | + clear_button: Button, | |
| 25 | + clear_all_button: Button, | |
| 26 | + refresh_button: Button, | |
| 27 | + | |
| 28 | + // State | |
| 29 | + original_paused: bool, | |
| 30 | + dirty: bool, | |
| 31 | + scroll_offset: i32, | |
| 32 | + instant_apply: bool, | |
| 33 | + pending_change: Option<&'static str>, | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl GarnotifyPanel { | |
| 37 | + pub fn new(mut adapter: GarnotifyAdapter) -> Self { | |
| 38 | + let (paused, pending, history) = if adapter.is_connected() { | |
| 39 | + if let Ok(status) = adapter.status() { | |
| 40 | + ( | |
| 41 | + status.paused, | |
| 42 | + format!("{} pending", status.pending_count), | |
| 43 | + format!("{} in history", status.history_count), | |
| 44 | + ) | |
| 45 | + } else { | |
| 46 | + (false, "Unknown".into(), "Unknown".into()) | |
| 47 | + } | |
| 48 | + } else { | |
| 49 | + (false, "N/A".into(), "N/A".into()) | |
| 50 | + }; | |
| 51 | + | |
| 52 | + Self { | |
| 53 | + adapter, | |
| 54 | + status_section: Section::new("Status"), | |
| 55 | + actions_section: Section::new("Actions"), | |
| 56 | + paused_toggle: Toggle::new("Paused", paused), | |
| 57 | + pending_count_label: Label::new("Pending", &pending), | |
| 58 | + history_count_label: Label::new("History", &history), | |
| 59 | + clear_button: Button::new("Clear Current"), | |
| 60 | + clear_all_button: Button::new("Clear All").primary(), | |
| 61 | + refresh_button: Button::new("Refresh"), | |
| 62 | + original_paused: paused, | |
| 63 | + dirty: false, | |
| 64 | + scroll_offset: 0, | |
| 65 | + instant_apply: true, | |
| 66 | + pending_change: None, | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + fn refresh_status(&mut self) { | |
| 71 | + if !self.adapter.is_connected() { | |
| 72 | + let _ = self.adapter.connect(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + if let Ok(status) = self.adapter.status() { | |
| 76 | + self.paused_toggle.value = status.paused; | |
| 77 | + self.original_paused = status.paused; | |
| 78 | + self.pending_count_label.text = format!("{} pending", status.pending_count); | |
| 79 | + self.history_count_label.text = format!("{} in history", status.history_count); | |
| 80 | + self.dirty = false; | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + fn check_dirty(&mut self) { | |
| 85 | + self.dirty = self.paused_toggle.value != self.original_paused; | |
| 86 | + } | |
| 87 | + | |
| 88 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 89 | + let padding = theme.padding as i32; | |
| 90 | + let row_height = 32; | |
| 91 | + let section_height = 32; | |
| 92 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 93 | + | |
| 94 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 95 | + | |
| 96 | + // Status section | |
| 97 | + self.status_section.bounds = | |
| 98 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 99 | + y += section_height; | |
| 100 | + | |
| 101 | + if self.status_section.expanded { | |
| 102 | + self.paused_toggle.bounds = | |
| 103 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 104 | + y += row_height; | |
| 105 | + | |
| 106 | + self.pending_count_label.bounds = | |
| 107 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 108 | + y += row_height; | |
| 109 | + | |
| 110 | + self.history_count_label.bounds = | |
| 111 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 112 | + y += row_height; | |
| 113 | + } | |
| 114 | + | |
| 115 | + y += padding / 2; | |
| 116 | + | |
| 117 | + // Actions section | |
| 118 | + self.actions_section.bounds = | |
| 119 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 120 | + y += section_height; | |
| 121 | + | |
| 122 | + if self.actions_section.expanded { | |
| 123 | + self.clear_button.bounds = Rect::new( | |
| 124 | + bounds.x + padding + 16, | |
| 125 | + y, | |
| 126 | + 110, | |
| 127 | + 32, | |
| 128 | + ); | |
| 129 | + | |
| 130 | + self.clear_all_button.bounds = Rect::new( | |
| 131 | + bounds.x + padding + 16 + 118, | |
| 132 | + y, | |
| 133 | + 90, | |
| 134 | + 32, | |
| 135 | + ); | |
| 136 | + | |
| 137 | + self.refresh_button.bounds = Rect::new( | |
| 138 | + bounds.x + padding + 16 + 118 + 98, | |
| 139 | + y, | |
| 140 | + 80, | |
| 141 | + 32, | |
| 142 | + ); | |
| 143 | + } | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +impl Panel for GarnotifyPanel { | |
| 148 | + fn name(&self) -> &str { | |
| 149 | + "garnotify" | |
| 150 | + } | |
| 151 | + | |
| 152 | + fn description(&self) -> &str { | |
| 153 | + "Notification daemon" | |
| 154 | + } | |
| 155 | + | |
| 156 | + fn is_dirty(&self) -> bool { | |
| 157 | + self.dirty | |
| 158 | + } | |
| 159 | + | |
| 160 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 161 | + self.layout_widgets(bounds, theme); | |
| 162 | + | |
| 163 | + // Render sections | |
| 164 | + self.status_section.render(renderer, theme)?; | |
| 165 | + if self.status_section.expanded { | |
| 166 | + self.paused_toggle.render(renderer, theme)?; | |
| 167 | + self.pending_count_label.render(renderer, theme)?; | |
| 168 | + self.history_count_label.render(renderer, theme)?; | |
| 169 | + } | |
| 170 | + | |
| 171 | + self.actions_section.render(renderer, theme)?; | |
| 172 | + if self.actions_section.expanded { | |
| 173 | + self.clear_button.render(renderer, theme)?; | |
| 174 | + self.clear_all_button.render(renderer, theme)?; | |
| 175 | + self.refresh_button.render(renderer, theme)?; | |
| 176 | + } | |
| 177 | + | |
| 178 | + // Connection status | |
| 179 | + let status_color = if self.adapter.is_connected() { | |
| 180 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 181 | + } else { | |
| 182 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 183 | + }; | |
| 184 | + | |
| 185 | + let status_style = TextStyle::new() | |
| 186 | + .font_family(&theme.font_family) | |
| 187 | + .font_size(theme.font_size * 0.85) | |
| 188 | + .color(status_color); | |
| 189 | + | |
| 190 | + let status_text = if self.adapter.is_connected() { | |
| 191 | + "Connected" | |
| 192 | + } else { | |
| 193 | + "Not running" | |
| 194 | + }; | |
| 195 | + | |
| 196 | + renderer.text( | |
| 197 | + status_text, | |
| 198 | + (bounds.x + theme.padding as i32) as f64, | |
| 199 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 200 | + &status_style, | |
| 201 | + )?; | |
| 202 | + | |
| 203 | + // Dirty indicator | |
| 204 | + if self.dirty { | |
| 205 | + let indicator_style = TextStyle::new() | |
| 206 | + .font_family(&theme.font_family) | |
| 207 | + .font_size(theme.font_size * 0.75) | |
| 208 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 209 | + | |
| 210 | + renderer.text( | |
| 211 | + "Unsaved changes", | |
| 212 | + (bounds.x + bounds.width as i32 - theme.padding as i32 - 100) as f64, | |
| 213 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 214 | + &indicator_style, | |
| 215 | + )?; | |
| 216 | + } | |
| 217 | + | |
| 218 | + Ok(()) | |
| 219 | + } | |
| 220 | + | |
| 221 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 222 | + match event { | |
| 223 | + InputEvent::MousePress(me) => { | |
| 224 | + let x = me.position.x; | |
| 225 | + let y = me.position.y; | |
| 226 | + | |
| 227 | + // Check sections | |
| 228 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 229 | + return PanelAction::Redraw; | |
| 230 | + } | |
| 231 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 232 | + return PanelAction::Redraw; | |
| 233 | + } | |
| 234 | + | |
| 235 | + // Toggle | |
| 236 | + if let WidgetEvent::Changed = self.paused_toggle.on_click(x, y) { | |
| 237 | + self.check_dirty(); | |
| 238 | + self.pending_change = Some("paused"); | |
| 239 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 240 | + } | |
| 241 | + | |
| 242 | + // Action buttons | |
| 243 | + if let WidgetEvent::Clicked = self.clear_button.on_click(x, y) { | |
| 244 | + if self.adapter.is_connected() { | |
| 245 | + let _ = self.adapter.clear(); | |
| 246 | + self.refresh_status(); | |
| 247 | + } | |
| 248 | + return PanelAction::Redraw; | |
| 249 | + } | |
| 250 | + if let WidgetEvent::Clicked = self.clear_all_button.on_click(x, y) { | |
| 251 | + if self.adapter.is_connected() { | |
| 252 | + let _ = self.adapter.clear_all(); | |
| 253 | + self.refresh_status(); | |
| 254 | + } | |
| 255 | + return PanelAction::Redraw; | |
| 256 | + } | |
| 257 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 258 | + self.refresh_status(); | |
| 259 | + return PanelAction::Redraw; | |
| 260 | + } | |
| 261 | + | |
| 262 | + PanelAction::None | |
| 263 | + } | |
| 264 | + InputEvent::Scroll(se) => { | |
| 265 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 266 | + PanelAction::Redraw | |
| 267 | + } | |
| 268 | + _ => PanelAction::None, | |
| 269 | + } | |
| 270 | + } | |
| 271 | + | |
| 272 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 273 | + let mut changed = false; | |
| 274 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 275 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 276 | + changed |= self.paused_toggle.on_mouse_move(x, y); | |
| 277 | + changed |= self.clear_button.on_mouse_move(x, y); | |
| 278 | + changed |= self.clear_all_button.on_mouse_move(x, y); | |
| 279 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 280 | + changed | |
| 281 | + } | |
| 282 | + | |
| 283 | + fn reset(&mut self) { | |
| 284 | + self.paused_toggle.value = self.original_paused; | |
| 285 | + self.dirty = false; | |
| 286 | + } | |
| 287 | + | |
| 288 | + fn apply(&mut self) -> Result<()> { | |
| 289 | + if !self.adapter.is_connected() { | |
| 290 | + self.adapter.connect()?; | |
| 291 | + } | |
| 292 | + | |
| 293 | + if self.paused_toggle.value != self.original_paused { | |
| 294 | + if self.paused_toggle.value { | |
| 295 | + self.adapter.pause()?; | |
| 296 | + } else { | |
| 297 | + self.adapter.resume()?; | |
| 298 | + } | |
| 299 | + self.original_paused = self.paused_toggle.value; | |
| 300 | + } | |
| 301 | + | |
| 302 | + self.dirty = false; | |
| 303 | + Ok(()) | |
| 304 | + } | |
| 305 | + | |
| 306 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 307 | + self.refresh_status(); | |
| 308 | + } | |
| 309 | + | |
| 310 | + fn set_instant_apply(&mut self, enabled: bool) { | |
| 311 | + self.instant_apply = enabled; | |
| 312 | + } | |
| 313 | + | |
| 314 | + fn instant_apply_enabled(&self) -> bool { | |
| 315 | + self.instant_apply | |
| 316 | + } | |
| 317 | + | |
| 318 | + fn apply_instant(&mut self) -> Result<()> { | |
| 319 | + if !self.adapter.is_connected() { | |
| 320 | + self.adapter.connect()?; | |
| 321 | + } | |
| 322 | + | |
| 323 | + if let Some(field) = self.pending_change.take() { | |
| 324 | + match field { | |
| 325 | + "paused" => { | |
| 326 | + if self.paused_toggle.value != self.original_paused { | |
| 327 | + if self.paused_toggle.value { | |
| 328 | + self.adapter.pause()?; | |
| 329 | + } else { | |
| 330 | + self.adapter.resume()?; | |
| 331 | + } | |
| 332 | + self.original_paused = self.paused_toggle.value; | |
| 333 | + } | |
| 334 | + } | |
| 335 | + _ => {} | |
| 336 | + } | |
| 337 | + self.check_dirty(); | |
| 338 | + } | |
| 339 | + | |
| 340 | + Ok(()) | |
| 341 | + } | |
| 342 | +} | |
gargears/src/panels/garshot.rsadded@@ -0,0 +1,256 @@ | ||
| 1 | +//! Configuration panel for garshot (screenshot tool) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GarshotAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garshot configuration panel | |
| 11 | +pub struct GarshotPanel { | |
| 12 | + adapter: GarshotAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + info_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + ready_label: Label, | |
| 20 | + monitors_label: Label, | |
| 21 | + | |
| 22 | + // Action buttons | |
| 23 | + refresh_button: Button, | |
| 24 | + | |
| 25 | + // State | |
| 26 | + scroll_offset: i32, | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl GarshotPanel { | |
| 30 | + pub fn new(mut adapter: GarshotAdapter) -> Self { | |
| 31 | + let ready = if adapter.is_connected() { | |
| 32 | + if let Ok(status) = adapter.status() { | |
| 33 | + if status.ready { "Ready" } else { "Not ready" } | |
| 34 | + } else { | |
| 35 | + "Unknown" | |
| 36 | + } | |
| 37 | + } else { | |
| 38 | + "Not connected" | |
| 39 | + }; | |
| 40 | + | |
| 41 | + let monitors = if adapter.is_connected() { | |
| 42 | + if let Ok(Some(monitors)) = adapter.list_monitors() { | |
| 43 | + if let Some(arr) = monitors.as_array() { | |
| 44 | + format!("{} monitor(s) detected", arr.len()) | |
| 45 | + } else { | |
| 46 | + "Monitors: Unknown format".into() | |
| 47 | + } | |
| 48 | + } else { | |
| 49 | + "No monitor info".into() | |
| 50 | + } | |
| 51 | + } else { | |
| 52 | + "Not connected".into() | |
| 53 | + }; | |
| 54 | + | |
| 55 | + Self { | |
| 56 | + adapter, | |
| 57 | + status_section: Section::new("Status"), | |
| 58 | + info_section: Section::new("Information"), | |
| 59 | + ready_label: Label::new("Status", ready), | |
| 60 | + monitors_label: Label::new("Monitors", &monitors), | |
| 61 | + refresh_button: Button::new("Refresh").primary(), | |
| 62 | + scroll_offset: 0, | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + fn refresh_status(&mut self) { | |
| 67 | + if !self.adapter.is_connected() { | |
| 68 | + let _ = self.adapter.connect(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + let ready = if self.adapter.is_connected() { | |
| 72 | + if let Ok(status) = self.adapter.status() { | |
| 73 | + if status.ready { "Ready" } else { "Not ready" } | |
| 74 | + } else { | |
| 75 | + "Error" | |
| 76 | + } | |
| 77 | + } else { | |
| 78 | + "Not connected" | |
| 79 | + }; | |
| 80 | + | |
| 81 | + let monitors = if self.adapter.is_connected() { | |
| 82 | + if let Ok(Some(monitors)) = self.adapter.list_monitors() { | |
| 83 | + if let Some(arr) = monitors.as_array() { | |
| 84 | + format!("{} monitor(s) detected", arr.len()) | |
| 85 | + } else { | |
| 86 | + "Monitors: Unknown format".into() | |
| 87 | + } | |
| 88 | + } else { | |
| 89 | + "No monitor info".into() | |
| 90 | + } | |
| 91 | + } else { | |
| 92 | + "Not connected".into() | |
| 93 | + }; | |
| 94 | + | |
| 95 | + self.ready_label.text = ready.into(); | |
| 96 | + self.monitors_label.text = monitors; | |
| 97 | + } | |
| 98 | + | |
| 99 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 100 | + let padding = theme.padding as i32; | |
| 101 | + let row_height = 32; | |
| 102 | + let section_height = 32; | |
| 103 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 104 | + | |
| 105 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 106 | + | |
| 107 | + // Status section | |
| 108 | + self.status_section.bounds = | |
| 109 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 110 | + y += section_height; | |
| 111 | + | |
| 112 | + if self.status_section.expanded { | |
| 113 | + self.ready_label.bounds = | |
| 114 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 115 | + y += row_height; | |
| 116 | + } | |
| 117 | + | |
| 118 | + y += padding / 2; | |
| 119 | + | |
| 120 | + // Info section | |
| 121 | + self.info_section.bounds = | |
| 122 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 123 | + y += section_height; | |
| 124 | + | |
| 125 | + if self.info_section.expanded { | |
| 126 | + self.monitors_label.bounds = | |
| 127 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 128 | + y += row_height; | |
| 129 | + | |
| 130 | + self.refresh_button.bounds = Rect::new( | |
| 131 | + bounds.x + padding + 16, | |
| 132 | + y + 8, | |
| 133 | + 100, | |
| 134 | + 32, | |
| 135 | + ); | |
| 136 | + } | |
| 137 | + } | |
| 138 | +} | |
| 139 | + | |
| 140 | +impl Panel for GarshotPanel { | |
| 141 | + fn name(&self) -> &str { | |
| 142 | + "garshot" | |
| 143 | + } | |
| 144 | + | |
| 145 | + fn description(&self) -> &str { | |
| 146 | + "Screenshot tool" | |
| 147 | + } | |
| 148 | + | |
| 149 | + fn is_dirty(&self) -> bool { | |
| 150 | + false | |
| 151 | + } | |
| 152 | + | |
| 153 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 154 | + self.layout_widgets(bounds, theme); | |
| 155 | + | |
| 156 | + // Render sections | |
| 157 | + self.status_section.render(renderer, theme)?; | |
| 158 | + if self.status_section.expanded { | |
| 159 | + self.ready_label.render(renderer, theme)?; | |
| 160 | + } | |
| 161 | + | |
| 162 | + self.info_section.render(renderer, theme)?; | |
| 163 | + if self.info_section.expanded { | |
| 164 | + self.monitors_label.render(renderer, theme)?; | |
| 165 | + self.refresh_button.render(renderer, theme)?; | |
| 166 | + } | |
| 167 | + | |
| 168 | + // Hint text | |
| 169 | + let hint_style = TextStyle::new() | |
| 170 | + .font_family(&theme.font_family) | |
| 171 | + .font_size(theme.font_size * 0.85) | |
| 172 | + .color(theme.item_description); | |
| 173 | + | |
| 174 | + renderer.text( | |
| 175 | + "Use keybinds or CLI to capture screenshots", | |
| 176 | + (bounds.x + theme.padding as i32) as f64, | |
| 177 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 40) as f64, | |
| 178 | + &hint_style, | |
| 179 | + )?; | |
| 180 | + | |
| 181 | + // Connection status | |
| 182 | + let status_color = if self.adapter.is_connected() { | |
| 183 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 184 | + } else { | |
| 185 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 186 | + }; | |
| 187 | + | |
| 188 | + let status_style = TextStyle::new() | |
| 189 | + .font_family(&theme.font_family) | |
| 190 | + .font_size(theme.font_size * 0.85) | |
| 191 | + .color(status_color); | |
| 192 | + | |
| 193 | + let status_text = if self.adapter.is_connected() { | |
| 194 | + "Connected" | |
| 195 | + } else { | |
| 196 | + "Not running" | |
| 197 | + }; | |
| 198 | + | |
| 199 | + renderer.text( | |
| 200 | + status_text, | |
| 201 | + (bounds.x + theme.padding as i32) as f64, | |
| 202 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 203 | + &status_style, | |
| 204 | + )?; | |
| 205 | + | |
| 206 | + Ok(()) | |
| 207 | + } | |
| 208 | + | |
| 209 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 210 | + match event { | |
| 211 | + InputEvent::MousePress(me) => { | |
| 212 | + let x = me.position.x; | |
| 213 | + let y = me.position.y; | |
| 214 | + | |
| 215 | + // Check sections | |
| 216 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 217 | + return PanelAction::Redraw; | |
| 218 | + } | |
| 219 | + if let WidgetEvent::Changed = self.info_section.on_click(x, y) { | |
| 220 | + return PanelAction::Redraw; | |
| 221 | + } | |
| 222 | + | |
| 223 | + // Refresh button | |
| 224 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 225 | + self.refresh_status(); | |
| 226 | + return PanelAction::Redraw; | |
| 227 | + } | |
| 228 | + | |
| 229 | + PanelAction::None | |
| 230 | + } | |
| 231 | + InputEvent::Scroll(se) => { | |
| 232 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 233 | + PanelAction::Redraw | |
| 234 | + } | |
| 235 | + _ => PanelAction::None, | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 240 | + let mut changed = false; | |
| 241 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 242 | + changed |= self.info_section.on_mouse_move(x, y); | |
| 243 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 244 | + changed | |
| 245 | + } | |
| 246 | + | |
| 247 | + fn reset(&mut self) {} | |
| 248 | + | |
| 249 | + fn apply(&mut self) -> Result<()> { | |
| 250 | + Ok(()) | |
| 251 | + } | |
| 252 | + | |
| 253 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 254 | + self.refresh_status(); | |
| 255 | + } | |
| 256 | +} | |
gargears/src/panels/garterm.rsadded@@ -0,0 +1,294 @@ | ||
| 1 | +//! Configuration panel for garterm (terminal emulator) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GartermAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Garterm configuration panel | |
| 11 | +pub struct GartermPanel { | |
| 12 | + adapter: GartermAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + actions_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + pid_label: Label, | |
| 20 | + title_label: Label, | |
| 21 | + tabs_label: Label, | |
| 22 | + instances_label: Label, | |
| 23 | + | |
| 24 | + // Action buttons | |
| 25 | + new_window_button: Button, | |
| 26 | + new_tab_button: Button, | |
| 27 | + refresh_button: Button, | |
| 28 | + | |
| 29 | + // State | |
| 30 | + scroll_offset: i32, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl GartermPanel { | |
| 34 | + pub fn new(mut adapter: GartermAdapter) -> Self { | |
| 35 | + let (pid, title, tabs) = if adapter.is_connected() { | |
| 36 | + if let Ok(status) = adapter.status() { | |
| 37 | + ( | |
| 38 | + status.pid.map(|p| format!("PID: {}", p)).unwrap_or_else(|| "PID: -".into()), | |
| 39 | + status.title.unwrap_or_else(|| "No title".into()), | |
| 40 | + status.tabs.map(|t| format!("{} tab(s)", t)).unwrap_or_else(|| "- tabs".into()), | |
| 41 | + ) | |
| 42 | + } else { | |
| 43 | + ("PID: -".into(), "Not connected".into(), "- tabs".into()) | |
| 44 | + } | |
| 45 | + } else { | |
| 46 | + ("PID: -".into(), "Not running".into(), "- tabs".into()) | |
| 47 | + }; | |
| 48 | + | |
| 49 | + let instances = GartermAdapter::list_instances(); | |
| 50 | + let instances_text = if instances.is_empty() { | |
| 51 | + "No instances running".into() | |
| 52 | + } else { | |
| 53 | + format!("{} instance(s): {}", instances.len(), | |
| 54 | + instances.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ")) | |
| 55 | + }; | |
| 56 | + | |
| 57 | + Self { | |
| 58 | + adapter, | |
| 59 | + status_section: Section::new("Status"), | |
| 60 | + actions_section: Section::new("Actions"), | |
| 61 | + pid_label: Label::new("Process", &pid), | |
| 62 | + title_label: Label::new("Title", &title), | |
| 63 | + tabs_label: Label::new("Tabs", &tabs), | |
| 64 | + instances_label: Label::new("Instances", &instances_text), | |
| 65 | + new_window_button: Button::new("New Window").primary(), | |
| 66 | + new_tab_button: Button::new("New Tab"), | |
| 67 | + refresh_button: Button::new("Refresh"), | |
| 68 | + scroll_offset: 0, | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + fn refresh_status(&mut self) { | |
| 73 | + // Reconnect to potentially new focused instance | |
| 74 | + let _ = self.adapter.connect(); | |
| 75 | + | |
| 76 | + let (pid, title, tabs) = if self.adapter.is_connected() { | |
| 77 | + if let Ok(status) = self.adapter.status() { | |
| 78 | + ( | |
| 79 | + status.pid.map(|p| format!("PID: {}", p)).unwrap_or_else(|| "PID: -".into()), | |
| 80 | + status.title.unwrap_or_else(|| "No title".into()), | |
| 81 | + status.tabs.map(|t| format!("{} tab(s)", t)).unwrap_or_else(|| "- tabs".into()), | |
| 82 | + ) | |
| 83 | + } else { | |
| 84 | + ("PID: -".into(), "Error getting status".into(), "- tabs".into()) | |
| 85 | + } | |
| 86 | + } else { | |
| 87 | + ("PID: -".into(), "Not running".into(), "- tabs".into()) | |
| 88 | + }; | |
| 89 | + | |
| 90 | + let instances = GartermAdapter::list_instances(); | |
| 91 | + let instances_text = if instances.is_empty() { | |
| 92 | + "No instances running".into() | |
| 93 | + } else { | |
| 94 | + format!("{} instance(s): {}", instances.len(), | |
| 95 | + instances.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ")) | |
| 96 | + }; | |
| 97 | + | |
| 98 | + self.pid_label.text = pid; | |
| 99 | + self.title_label.text = title; | |
| 100 | + self.tabs_label.text = tabs; | |
| 101 | + self.instances_label.text = instances_text; | |
| 102 | + } | |
| 103 | + | |
| 104 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 105 | + let padding = theme.padding as i32; | |
| 106 | + let row_height = 32; | |
| 107 | + let section_height = 32; | |
| 108 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 109 | + | |
| 110 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 111 | + | |
| 112 | + // Status section | |
| 113 | + self.status_section.bounds = | |
| 114 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 115 | + y += section_height; | |
| 116 | + | |
| 117 | + if self.status_section.expanded { | |
| 118 | + self.pid_label.bounds = | |
| 119 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 120 | + y += row_height; | |
| 121 | + | |
| 122 | + self.title_label.bounds = | |
| 123 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 124 | + y += row_height; | |
| 125 | + | |
| 126 | + self.tabs_label.bounds = | |
| 127 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 128 | + y += row_height; | |
| 129 | + | |
| 130 | + self.instances_label.bounds = | |
| 131 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 132 | + y += row_height; | |
| 133 | + } | |
| 134 | + | |
| 135 | + y += padding / 2; | |
| 136 | + | |
| 137 | + // Actions section | |
| 138 | + self.actions_section.bounds = | |
| 139 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 140 | + y += section_height; | |
| 141 | + | |
| 142 | + if self.actions_section.expanded { | |
| 143 | + let button_width = 100; | |
| 144 | + let button_spacing = 8; | |
| 145 | + | |
| 146 | + self.new_window_button.bounds = Rect::new( | |
| 147 | + bounds.x + padding + 16, | |
| 148 | + y, | |
| 149 | + button_width, | |
| 150 | + 32, | |
| 151 | + ); | |
| 152 | + | |
| 153 | + self.new_tab_button.bounds = Rect::new( | |
| 154 | + bounds.x + padding + 16 + button_width as i32 + button_spacing, | |
| 155 | + y, | |
| 156 | + button_width, | |
| 157 | + 32, | |
| 158 | + ); | |
| 159 | + | |
| 160 | + self.refresh_button.bounds = Rect::new( | |
| 161 | + bounds.x + padding + 16 + (button_width as i32 + button_spacing) * 2, | |
| 162 | + y, | |
| 163 | + button_width, | |
| 164 | + 32, | |
| 165 | + ); | |
| 166 | + } | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +impl Panel for GartermPanel { | |
| 171 | + fn name(&self) -> &str { | |
| 172 | + "garterm" | |
| 173 | + } | |
| 174 | + | |
| 175 | + fn description(&self) -> &str { | |
| 176 | + "Terminal emulator" | |
| 177 | + } | |
| 178 | + | |
| 179 | + fn is_dirty(&self) -> bool { | |
| 180 | + false // Status display only, no editable settings yet | |
| 181 | + } | |
| 182 | + | |
| 183 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 184 | + self.layout_widgets(bounds, theme); | |
| 185 | + | |
| 186 | + // Render sections | |
| 187 | + self.status_section.render(renderer, theme)?; | |
| 188 | + if self.status_section.expanded { | |
| 189 | + self.pid_label.render(renderer, theme)?; | |
| 190 | + self.title_label.render(renderer, theme)?; | |
| 191 | + self.tabs_label.render(renderer, theme)?; | |
| 192 | + self.instances_label.render(renderer, theme)?; | |
| 193 | + } | |
| 194 | + | |
| 195 | + self.actions_section.render(renderer, theme)?; | |
| 196 | + if self.actions_section.expanded { | |
| 197 | + self.new_window_button.render(renderer, theme)?; | |
| 198 | + self.new_tab_button.render(renderer, theme)?; | |
| 199 | + self.refresh_button.render(renderer, theme)?; | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Connection status at bottom | |
| 203 | + let status_color = if self.adapter.is_connected() { | |
| 204 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 205 | + } else { | |
| 206 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 207 | + }; | |
| 208 | + | |
| 209 | + let status_style = TextStyle::new() | |
| 210 | + .font_family(&theme.font_family) | |
| 211 | + .font_size(theme.font_size * 0.85) | |
| 212 | + .color(status_color); | |
| 213 | + | |
| 214 | + let status_text = if self.adapter.is_connected() { | |
| 215 | + "Connected to focused instance" | |
| 216 | + } else { | |
| 217 | + "No garterm instance focused" | |
| 218 | + }; | |
| 219 | + | |
| 220 | + renderer.text( | |
| 221 | + status_text, | |
| 222 | + (bounds.x + theme.padding as i32) as f64, | |
| 223 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 224 | + &status_style, | |
| 225 | + )?; | |
| 226 | + | |
| 227 | + Ok(()) | |
| 228 | + } | |
| 229 | + | |
| 230 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 231 | + match event { | |
| 232 | + InputEvent::MousePress(me) => { | |
| 233 | + let x = me.position.x; | |
| 234 | + let y = me.position.y; | |
| 235 | + | |
| 236 | + // Check sections | |
| 237 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 238 | + return PanelAction::Redraw; | |
| 239 | + } | |
| 240 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 241 | + return PanelAction::Redraw; | |
| 242 | + } | |
| 243 | + | |
| 244 | + // Action buttons | |
| 245 | + if let WidgetEvent::Clicked = self.new_window_button.on_click(x, y) { | |
| 246 | + if self.adapter.is_connected() { | |
| 247 | + let _ = self.adapter.new_window(); | |
| 248 | + } | |
| 249 | + return PanelAction::Redraw; | |
| 250 | + } | |
| 251 | + if let WidgetEvent::Clicked = self.new_tab_button.on_click(x, y) { | |
| 252 | + if self.adapter.is_connected() { | |
| 253 | + let _ = self.adapter.new_tab(); | |
| 254 | + } | |
| 255 | + return PanelAction::Redraw; | |
| 256 | + } | |
| 257 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 258 | + self.refresh_status(); | |
| 259 | + return PanelAction::Redraw; | |
| 260 | + } | |
| 261 | + | |
| 262 | + PanelAction::None | |
| 263 | + } | |
| 264 | + InputEvent::Scroll(se) => { | |
| 265 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 266 | + PanelAction::Redraw | |
| 267 | + } | |
| 268 | + _ => PanelAction::None, | |
| 269 | + } | |
| 270 | + } | |
| 271 | + | |
| 272 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 273 | + let mut changed = false; | |
| 274 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 275 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 276 | + changed |= self.new_window_button.on_mouse_move(x, y); | |
| 277 | + changed |= self.new_tab_button.on_mouse_move(x, y); | |
| 278 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 279 | + changed | |
| 280 | + } | |
| 281 | + | |
| 282 | + fn reset(&mut self) { | |
| 283 | + // Nothing to reset | |
| 284 | + } | |
| 285 | + | |
| 286 | + fn apply(&mut self) -> Result<()> { | |
| 287 | + // No settings to apply | |
| 288 | + Ok(()) | |
| 289 | + } | |
| 290 | + | |
| 291 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 292 | + self.refresh_status(); | |
| 293 | + } | |
| 294 | +} | |
gargears/src/panels/gartray.rsadded@@ -0,0 +1,351 @@ | ||
| 1 | +//! Configuration panel for gartray (system tray & quick settings) | |
| 2 | + | |
| 3 | +use crate::ipc::adapters::GartrayAdapter; | |
| 4 | +use crate::ui::widgets::{Button, Label, Section, Toggle, WidgetEvent}; | |
| 5 | +use crate::ui::{Panel, PanelAction}; | |
| 6 | +use anyhow::Result; | |
| 7 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 8 | +use gartk_render::{Renderer, TextStyle}; | |
| 9 | + | |
| 10 | +/// Gartray configuration panel | |
| 11 | +pub struct GartrayPanel { | |
| 12 | + adapter: GartrayAdapter, | |
| 13 | + | |
| 14 | + // Sections | |
| 15 | + status_section: Section, | |
| 16 | + actions_section: Section, | |
| 17 | + | |
| 18 | + // Status display | |
| 19 | + visible_toggle: Toggle, | |
| 20 | + tray_icons_label: Label, | |
| 21 | + | |
| 22 | + // Action buttons | |
| 23 | + show_button: Button, | |
| 24 | + hide_button: Button, | |
| 25 | + toggle_button: Button, | |
| 26 | + refresh_button: Button, | |
| 27 | + | |
| 28 | + // State | |
| 29 | + original_visible: bool, | |
| 30 | + dirty: bool, | |
| 31 | + scroll_offset: i32, | |
| 32 | + instant_apply: bool, | |
| 33 | + pending_change: Option<&'static str>, | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl GartrayPanel { | |
| 37 | + pub fn new(mut adapter: GartrayAdapter) -> Self { | |
| 38 | + let (visible, tray_icons) = if adapter.is_connected() { | |
| 39 | + if let Ok(status) = adapter.status() { | |
| 40 | + (status.visible, format!("{} tray icon(s)", status.tray_icons)) | |
| 41 | + } else { | |
| 42 | + (false, "Unknown".into()) | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + (false, "Not connected".into()) | |
| 46 | + }; | |
| 47 | + | |
| 48 | + Self { | |
| 49 | + adapter, | |
| 50 | + status_section: Section::new("Status"), | |
| 51 | + actions_section: Section::new("Actions"), | |
| 52 | + visible_toggle: Toggle::new("Visible", visible), | |
| 53 | + tray_icons_label: Label::new("Tray Icons", &tray_icons), | |
| 54 | + show_button: Button::new("Show"), | |
| 55 | + hide_button: Button::new("Hide"), | |
| 56 | + toggle_button: Button::new("Toggle").primary(), | |
| 57 | + refresh_button: Button::new("Refresh"), | |
| 58 | + original_visible: visible, | |
| 59 | + dirty: false, | |
| 60 | + scroll_offset: 0, | |
| 61 | + instant_apply: true, | |
| 62 | + pending_change: None, | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + fn refresh_status(&mut self) { | |
| 67 | + if !self.adapter.is_connected() { | |
| 68 | + let _ = self.adapter.connect(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + if let Ok(status) = self.adapter.status() { | |
| 72 | + self.visible_toggle.value = status.visible; | |
| 73 | + self.original_visible = status.visible; | |
| 74 | + self.tray_icons_label.text = format!("{} tray icon(s)", status.tray_icons); | |
| 75 | + self.dirty = false; | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + fn check_dirty(&mut self) { | |
| 80 | + self.dirty = self.visible_toggle.value != self.original_visible; | |
| 81 | + } | |
| 82 | + | |
| 83 | + fn layout_widgets(&mut self, bounds: Rect, theme: &Theme) { | |
| 84 | + let padding = theme.padding as i32; | |
| 85 | + let row_height = 32; | |
| 86 | + let section_height = 32; | |
| 87 | + let widget_width = bounds.width - (padding * 2) as u32; | |
| 88 | + | |
| 89 | + let mut y = bounds.y + padding - self.scroll_offset; | |
| 90 | + | |
| 91 | + // Status section | |
| 92 | + self.status_section.bounds = | |
| 93 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 94 | + y += section_height; | |
| 95 | + | |
| 96 | + if self.status_section.expanded { | |
| 97 | + self.visible_toggle.bounds = | |
| 98 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 99 | + y += row_height; | |
| 100 | + | |
| 101 | + self.tray_icons_label.bounds = | |
| 102 | + Rect::new(bounds.x + padding + 16, y, widget_width - 16, row_height as u32); | |
| 103 | + y += row_height; | |
| 104 | + } | |
| 105 | + | |
| 106 | + y += padding / 2; | |
| 107 | + | |
| 108 | + // Actions section | |
| 109 | + self.actions_section.bounds = | |
| 110 | + Rect::new(bounds.x + padding, y, widget_width, section_height as u32); | |
| 111 | + y += section_height; | |
| 112 | + | |
| 113 | + if self.actions_section.expanded { | |
| 114 | + let button_width = 80; | |
| 115 | + let button_spacing = 8; | |
| 116 | + | |
| 117 | + self.show_button.bounds = Rect::new( | |
| 118 | + bounds.x + padding + 16, | |
| 119 | + y, | |
| 120 | + button_width, | |
| 121 | + 32, | |
| 122 | + ); | |
| 123 | + | |
| 124 | + self.hide_button.bounds = Rect::new( | |
| 125 | + bounds.x + padding + 16 + button_width as i32 + button_spacing, | |
| 126 | + y, | |
| 127 | + button_width, | |
| 128 | + 32, | |
| 129 | + ); | |
| 130 | + | |
| 131 | + self.toggle_button.bounds = Rect::new( | |
| 132 | + bounds.x + padding + 16 + (button_width as i32 + button_spacing) * 2, | |
| 133 | + y, | |
| 134 | + button_width, | |
| 135 | + 32, | |
| 136 | + ); | |
| 137 | + | |
| 138 | + self.refresh_button.bounds = Rect::new( | |
| 139 | + bounds.x + padding + 16 + (button_width as i32 + button_spacing) * 3, | |
| 140 | + y, | |
| 141 | + button_width, | |
| 142 | + 32, | |
| 143 | + ); | |
| 144 | + } | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +impl Panel for GartrayPanel { | |
| 149 | + fn name(&self) -> &str { | |
| 150 | + "gartray" | |
| 151 | + } | |
| 152 | + | |
| 153 | + fn description(&self) -> &str { | |
| 154 | + "System tray & quick settings" | |
| 155 | + } | |
| 156 | + | |
| 157 | + fn is_dirty(&self) -> bool { | |
| 158 | + self.dirty | |
| 159 | + } | |
| 160 | + | |
| 161 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 162 | + self.layout_widgets(bounds, theme); | |
| 163 | + | |
| 164 | + // Render sections | |
| 165 | + self.status_section.render(renderer, theme)?; | |
| 166 | + if self.status_section.expanded { | |
| 167 | + self.visible_toggle.render(renderer, theme)?; | |
| 168 | + self.tray_icons_label.render(renderer, theme)?; | |
| 169 | + } | |
| 170 | + | |
| 171 | + self.actions_section.render(renderer, theme)?; | |
| 172 | + if self.actions_section.expanded { | |
| 173 | + self.show_button.render(renderer, theme)?; | |
| 174 | + self.hide_button.render(renderer, theme)?; | |
| 175 | + self.toggle_button.render(renderer, theme)?; | |
| 176 | + self.refresh_button.render(renderer, theme)?; | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Connection status | |
| 180 | + let status_color = if self.adapter.is_connected() { | |
| 181 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) | |
| 182 | + } else { | |
| 183 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) | |
| 184 | + }; | |
| 185 | + | |
| 186 | + let status_style = TextStyle::new() | |
| 187 | + .font_family(&theme.font_family) | |
| 188 | + .font_size(theme.font_size * 0.85) | |
| 189 | + .color(status_color); | |
| 190 | + | |
| 191 | + let status_text = if self.adapter.is_connected() { | |
| 192 | + "Connected" | |
| 193 | + } else { | |
| 194 | + "Not running" | |
| 195 | + }; | |
| 196 | + | |
| 197 | + renderer.text( | |
| 198 | + status_text, | |
| 199 | + (bounds.x + theme.padding as i32) as f64, | |
| 200 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 201 | + &status_style, | |
| 202 | + )?; | |
| 203 | + | |
| 204 | + // Dirty indicator | |
| 205 | + if self.dirty { | |
| 206 | + let indicator_style = TextStyle::new() | |
| 207 | + .font_family(&theme.font_family) | |
| 208 | + .font_size(theme.font_size * 0.75) | |
| 209 | + .color(gartk_core::Color::from_u8(0xff, 0xb8, 0x6c, 0xff)); | |
| 210 | + | |
| 211 | + renderer.text( | |
| 212 | + "Unsaved changes", | |
| 213 | + (bounds.x + bounds.width as i32 - theme.padding as i32 - 100) as f64, | |
| 214 | + (bounds.y + bounds.height as i32 - theme.padding as i32 - 16) as f64, | |
| 215 | + &indicator_style, | |
| 216 | + )?; | |
| 217 | + } | |
| 218 | + | |
| 219 | + Ok(()) | |
| 220 | + } | |
| 221 | + | |
| 222 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction { | |
| 223 | + match event { | |
| 224 | + InputEvent::MousePress(me) => { | |
| 225 | + let x = me.position.x; | |
| 226 | + let y = me.position.y; | |
| 227 | + | |
| 228 | + // Check sections | |
| 229 | + if let WidgetEvent::Changed = self.status_section.on_click(x, y) { | |
| 230 | + return PanelAction::Redraw; | |
| 231 | + } | |
| 232 | + if let WidgetEvent::Changed = self.actions_section.on_click(x, y) { | |
| 233 | + return PanelAction::Redraw; | |
| 234 | + } | |
| 235 | + | |
| 236 | + // Toggle | |
| 237 | + if let WidgetEvent::Changed = self.visible_toggle.on_click(x, y) { | |
| 238 | + self.check_dirty(); | |
| 239 | + self.pending_change = Some("visible"); | |
| 240 | + return if self.instant_apply { PanelAction::InstantApply } else { PanelAction::Redraw }; | |
| 241 | + } | |
| 242 | + | |
| 243 | + // Action buttons | |
| 244 | + if let WidgetEvent::Clicked = self.show_button.on_click(x, y) { | |
| 245 | + if self.adapter.is_connected() { | |
| 246 | + let _ = self.adapter.show(); | |
| 247 | + self.refresh_status(); | |
| 248 | + } | |
| 249 | + return PanelAction::Redraw; | |
| 250 | + } | |
| 251 | + if let WidgetEvent::Clicked = self.hide_button.on_click(x, y) { | |
| 252 | + if self.adapter.is_connected() { | |
| 253 | + let _ = self.adapter.hide(); | |
| 254 | + self.refresh_status(); | |
| 255 | + } | |
| 256 | + return PanelAction::Redraw; | |
| 257 | + } | |
| 258 | + if let WidgetEvent::Clicked = self.toggle_button.on_click(x, y) { | |
| 259 | + if self.adapter.is_connected() { | |
| 260 | + let _ = self.adapter.toggle(); | |
| 261 | + self.refresh_status(); | |
| 262 | + } | |
| 263 | + return PanelAction::Redraw; | |
| 264 | + } | |
| 265 | + if let WidgetEvent::Clicked = self.refresh_button.on_click(x, y) { | |
| 266 | + self.refresh_status(); | |
| 267 | + return PanelAction::Redraw; | |
| 268 | + } | |
| 269 | + | |
| 270 | + PanelAction::None | |
| 271 | + } | |
| 272 | + InputEvent::Scroll(se) => { | |
| 273 | + self.scroll_offset = (self.scroll_offset - se.delta_y * 20).max(0); | |
| 274 | + PanelAction::Redraw | |
| 275 | + } | |
| 276 | + _ => PanelAction::None, | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 281 | + let mut changed = false; | |
| 282 | + changed |= self.status_section.on_mouse_move(x, y); | |
| 283 | + changed |= self.actions_section.on_mouse_move(x, y); | |
| 284 | + changed |= self.visible_toggle.on_mouse_move(x, y); | |
| 285 | + changed |= self.show_button.on_mouse_move(x, y); | |
| 286 | + changed |= self.hide_button.on_mouse_move(x, y); | |
| 287 | + changed |= self.toggle_button.on_mouse_move(x, y); | |
| 288 | + changed |= self.refresh_button.on_mouse_move(x, y); | |
| 289 | + changed | |
| 290 | + } | |
| 291 | + | |
| 292 | + fn reset(&mut self) { | |
| 293 | + self.visible_toggle.value = self.original_visible; | |
| 294 | + self.dirty = false; | |
| 295 | + } | |
| 296 | + | |
| 297 | + fn apply(&mut self) -> Result<()> { | |
| 298 | + if !self.adapter.is_connected() { | |
| 299 | + self.adapter.connect()?; | |
| 300 | + } | |
| 301 | + | |
| 302 | + if self.visible_toggle.value != self.original_visible { | |
| 303 | + if self.visible_toggle.value { | |
| 304 | + self.adapter.show()?; | |
| 305 | + } else { | |
| 306 | + self.adapter.hide()?; | |
| 307 | + } | |
| 308 | + self.original_visible = self.visible_toggle.value; | |
| 309 | + } | |
| 310 | + | |
| 311 | + self.dirty = false; | |
| 312 | + Ok(()) | |
| 313 | + } | |
| 314 | + | |
| 315 | + fn update_status(&mut self, _status: serde_json::Value) { | |
| 316 | + self.refresh_status(); | |
| 317 | + } | |
| 318 | + | |
| 319 | + fn set_instant_apply(&mut self, enabled: bool) { | |
| 320 | + self.instant_apply = enabled; | |
| 321 | + } | |
| 322 | + | |
| 323 | + fn instant_apply_enabled(&self) -> bool { | |
| 324 | + self.instant_apply | |
| 325 | + } | |
| 326 | + | |
| 327 | + fn apply_instant(&mut self) -> Result<()> { | |
| 328 | + if !self.adapter.is_connected() { | |
| 329 | + self.adapter.connect()?; | |
| 330 | + } | |
| 331 | + | |
| 332 | + if let Some(field) = self.pending_change.take() { | |
| 333 | + match field { | |
| 334 | + "visible" => { | |
| 335 | + if self.visible_toggle.value != self.original_visible { | |
| 336 | + if self.visible_toggle.value { | |
| 337 | + self.adapter.show()?; | |
| 338 | + } else { | |
| 339 | + self.adapter.hide()?; | |
| 340 | + } | |
| 341 | + self.original_visible = self.visible_toggle.value; | |
| 342 | + } | |
| 343 | + } | |
| 344 | + _ => {} | |
| 345 | + } | |
| 346 | + self.check_dirty(); | |
| 347 | + } | |
| 348 | + | |
| 349 | + Ok(()) | |
| 350 | + } | |
| 351 | +} | |
gargears/src/panels/mod.rsadded@@ -0,0 +1,116 @@ | ||
| 1 | +//! Configuration panels for each gardesk component | |
| 2 | + | |
| 3 | +mod gar; | |
| 4 | +mod garbar; | |
| 5 | +mod garbg; | |
| 6 | +mod garclip; | |
| 7 | +mod garfield; | |
| 8 | +mod garlaunch; | |
| 9 | +mod garlock; | |
| 10 | +mod garnotify; | |
| 11 | +mod garshot; | |
| 12 | +mod garterm; | |
| 13 | +mod gartray; | |
| 14 | +mod placeholder; | |
| 15 | + | |
| 16 | +pub use gar::GarPanel; | |
| 17 | +pub use garbar::GarbarPanel; | |
| 18 | +pub use garbg::GarbgPanel; | |
| 19 | +pub use garclip::GarclipPanel; | |
| 20 | +pub use garfield::GarfieldPanel; | |
| 21 | +pub use garlaunch::GarlaunchPanel; | |
| 22 | +pub use garlock::GarlockPanel; | |
| 23 | +pub use garnotify::GarnotifyPanel; | |
| 24 | +pub use garshot::GarshotPanel; | |
| 25 | +pub use garterm::GartermPanel; | |
| 26 | +pub use gartray::GartrayPanel; | |
| 27 | +pub use placeholder::PlaceholderPanel; | |
| 28 | + | |
| 29 | +use serde::{Deserialize, Serialize}; | |
| 30 | + | |
| 31 | +/// All gardesk components that can be configured | |
| 32 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 33 | +pub enum Component { | |
| 34 | + Gar, | |
| 35 | + Garbar, | |
| 36 | + Garbg, | |
| 37 | + Garterm, | |
| 38 | + Gartray, | |
| 39 | + Garshot, | |
| 40 | + Garlock, | |
| 41 | + Garfield, | |
| 42 | + Garclip, | |
| 43 | + Garlaunch, | |
| 44 | + Garnotify, | |
| 45 | +} | |
| 46 | + | |
| 47 | +impl Component { | |
| 48 | + /// Get all components in display order | |
| 49 | + pub fn all() -> Vec<Component> { | |
| 50 | + vec![ | |
| 51 | + Component::Gar, | |
| 52 | + Component::Garbar, | |
| 53 | + Component::Garbg, | |
| 54 | + Component::Garterm, | |
| 55 | + Component::Gartray, | |
| 56 | + Component::Garshot, | |
| 57 | + Component::Garlock, | |
| 58 | + Component::Garfield, | |
| 59 | + Component::Garclip, | |
| 60 | + Component::Garlaunch, | |
| 61 | + Component::Garnotify, | |
| 62 | + ] | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Get display name for a component | |
| 66 | + pub fn display_name(&self) -> &'static str { | |
| 67 | + match self { | |
| 68 | + Component::Gar => "gar", | |
| 69 | + Component::Garbar => "garbar", | |
| 70 | + Component::Garbg => "garbg", | |
| 71 | + Component::Garterm => "garterm", | |
| 72 | + Component::Gartray => "gartray", | |
| 73 | + Component::Garshot => "garshot", | |
| 74 | + Component::Garlock => "garlock", | |
| 75 | + Component::Garfield => "garfield", | |
| 76 | + Component::Garclip => "garclip", | |
| 77 | + Component::Garlaunch => "garlaunch", | |
| 78 | + Component::Garnotify => "garnotify", | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// Get description for a component | |
| 83 | + pub fn description(&self) -> &'static str { | |
| 84 | + match self { | |
| 85 | + Component::Gar => "Tiling window manager", | |
| 86 | + Component::Garbar => "Status bar", | |
| 87 | + Component::Garbg => "Wallpaper daemon", | |
| 88 | + Component::Garterm => "Terminal emulator", | |
| 89 | + Component::Gartray => "System tray & quick settings", | |
| 90 | + Component::Garshot => "Screenshot tool", | |
| 91 | + Component::Garlock => "Screen locker", | |
| 92 | + Component::Garfield => "File explorer", | |
| 93 | + Component::Garclip => "Clipboard manager", | |
| 94 | + Component::Garlaunch => "Application launcher", | |
| 95 | + Component::Garnotify => "Notification daemon", | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + /// Parse a component from a name | |
| 100 | + pub fn from_name(name: &str) -> Option<Component> { | |
| 101 | + match name.to_lowercase().as_str() { | |
| 102 | + "gar" => Some(Component::Gar), | |
| 103 | + "garbar" => Some(Component::Garbar), | |
| 104 | + "garbg" => Some(Component::Garbg), | |
| 105 | + "garterm" => Some(Component::Garterm), | |
| 106 | + "gartray" => Some(Component::Gartray), | |
| 107 | + "garshot" => Some(Component::Garshot), | |
| 108 | + "garlock" => Some(Component::Garlock), | |
| 109 | + "garfield" => Some(Component::Garfield), | |
| 110 | + "garclip" => Some(Component::Garclip), | |
| 111 | + "garlaunch" => Some(Component::Garlaunch), | |
| 112 | + "garnotify" => Some(Component::Garnotify), | |
| 113 | + _ => None, | |
| 114 | + } | |
| 115 | + } | |
| 116 | +} | |
gargears/src/panels/placeholder.rsadded@@ -0,0 +1,123 @@ | ||
| 1 | +//! Placeholder panel for components without full implementations | |
| 2 | + | |
| 3 | +use crate::ui::{Panel, PanelAction}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// Placeholder panel for components not yet implemented | |
| 9 | +pub struct PlaceholderPanel { | |
| 10 | + name: String, | |
| 11 | + description: String, | |
| 12 | + connected: bool, | |
| 13 | +} | |
| 14 | + | |
| 15 | +impl PlaceholderPanel { | |
| 16 | + pub fn new(name: impl Into<String>, description: impl Into<String>, connected: bool) -> Self { | |
| 17 | + Self { | |
| 18 | + name: name.into(), | |
| 19 | + description: description.into(), | |
| 20 | + connected, | |
| 21 | + } | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +impl Panel for PlaceholderPanel { | |
| 26 | + fn name(&self) -> &str { | |
| 27 | + &self.name | |
| 28 | + } | |
| 29 | + | |
| 30 | + fn description(&self) -> &str { | |
| 31 | + &self.description | |
| 32 | + } | |
| 33 | + | |
| 34 | + fn is_dirty(&self) -> bool { | |
| 35 | + false | |
| 36 | + } | |
| 37 | + | |
| 38 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()> { | |
| 39 | + let padding = theme.padding as i32; | |
| 40 | + | |
| 41 | + // Title | |
| 42 | + let title_style = TextStyle::new() | |
| 43 | + .font_family(&theme.font_family) | |
| 44 | + .font_size(theme.font_size + 4.0) | |
| 45 | + .color(theme.foreground); | |
| 46 | + | |
| 47 | + renderer.text( | |
| 48 | + &self.name, | |
| 49 | + (bounds.x + padding) as f64, | |
| 50 | + (bounds.y + padding) as f64, | |
| 51 | + &title_style, | |
| 52 | + )?; | |
| 53 | + | |
| 54 | + // Description | |
| 55 | + let desc_style = TextStyle::new() | |
| 56 | + .font_family(&theme.font_family) | |
| 57 | + .font_size(theme.font_size) | |
| 58 | + .color(theme.item_description); | |
| 59 | + | |
| 60 | + renderer.text( | |
| 61 | + &self.description, | |
| 62 | + (bounds.x + padding) as f64, | |
| 63 | + (bounds.y + padding + theme.font_size as i32 + 12) as f64, | |
| 64 | + &desc_style, | |
| 65 | + )?; | |
| 66 | + | |
| 67 | + // Connection status | |
| 68 | + let status_color = if self.connected { | |
| 69 | + gartk_core::Color::from_u8(0x50, 0xfa, 0x7b, 0xff) // green | |
| 70 | + } else { | |
| 71 | + gartk_core::Color::from_u8(0xff, 0x55, 0x55, 0xff) // red | |
| 72 | + }; | |
| 73 | + | |
| 74 | + let status_style = TextStyle::new() | |
| 75 | + .font_family(&theme.font_family) | |
| 76 | + .font_size(theme.font_size) | |
| 77 | + .color(status_color); | |
| 78 | + | |
| 79 | + let status_text = if self.connected { | |
| 80 | + "● Connected" | |
| 81 | + } else { | |
| 82 | + "○ Not running" | |
| 83 | + }; | |
| 84 | + | |
| 85 | + renderer.text( | |
| 86 | + status_text, | |
| 87 | + (bounds.x + padding) as f64, | |
| 88 | + (bounds.y + padding + (theme.font_size as i32 + 12) * 2) as f64, | |
| 89 | + &status_style, | |
| 90 | + )?; | |
| 91 | + | |
| 92 | + // Coming soon message | |
| 93 | + let coming_style = TextStyle::new() | |
| 94 | + .font_family(&theme.font_family) | |
| 95 | + .font_size(theme.font_size) | |
| 96 | + .color(theme.item_description); | |
| 97 | + | |
| 98 | + renderer.text( | |
| 99 | + "Configuration panel coming soon...", | |
| 100 | + (bounds.x + padding) as f64, | |
| 101 | + (bounds.y + bounds.height as i32 / 2) as f64, | |
| 102 | + &coming_style, | |
| 103 | + )?; | |
| 104 | + | |
| 105 | + Ok(()) | |
| 106 | + } | |
| 107 | + | |
| 108 | + fn handle_event(&mut self, _event: &InputEvent) -> PanelAction { | |
| 109 | + PanelAction::None | |
| 110 | + } | |
| 111 | + | |
| 112 | + fn on_mouse_move(&mut self, _x: i32, _y: i32) -> bool { | |
| 113 | + false | |
| 114 | + } | |
| 115 | + | |
| 116 | + fn reset(&mut self) {} | |
| 117 | + | |
| 118 | + fn apply(&mut self) -> Result<()> { | |
| 119 | + Ok(()) | |
| 120 | + } | |
| 121 | + | |
| 122 | + fn update_status(&mut self, _status: serde_json::Value) {} | |
| 123 | +} | |
gargears/src/ui/layout.rsadded@@ -0,0 +1,64 @@ | ||
| 1 | +//! Layout calculations for gargears | |
| 2 | + | |
| 3 | +use gartk_core::Rect; | |
| 4 | + | |
| 5 | +/// Layout constants | |
| 6 | +const SIDEBAR_WIDTH: u32 = 200; | |
| 7 | +const PADDING: i32 = 12; | |
| 8 | +const GAP: i32 = 8; | |
| 9 | + | |
| 10 | +/// Layout manager | |
| 11 | +pub struct Layout { | |
| 12 | + width: u32, | |
| 13 | + height: u32, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl Layout { | |
| 17 | + /// Create a new layout for the given window size | |
| 18 | + pub fn new(width: u32, height: u32) -> Self { | |
| 19 | + Self { width, height } | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// Get the sidebar bounds | |
| 23 | + pub fn sidebar_bounds(&self) -> Rect { | |
| 24 | + Rect::new( | |
| 25 | + PADDING, | |
| 26 | + PADDING, | |
| 27 | + SIDEBAR_WIDTH, | |
| 28 | + self.height - (PADDING * 2) as u32, | |
| 29 | + ) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Get the content panel bounds | |
| 33 | + pub fn content_bounds(&self) -> Rect { | |
| 34 | + let x = PADDING + SIDEBAR_WIDTH as i32 + GAP; | |
| 35 | + Rect::new( | |
| 36 | + x, | |
| 37 | + PADDING, | |
| 38 | + self.width - x as u32 - PADDING as u32, | |
| 39 | + self.height - (PADDING * 2) as u32, | |
| 40 | + ) | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Get the action buttons bounds (bottom of content panel) | |
| 44 | + pub fn action_bounds(&self) -> Rect { | |
| 45 | + let content = self.content_bounds(); | |
| 46 | + let button_height = 40; | |
| 47 | + Rect::new( | |
| 48 | + content.x, | |
| 49 | + content.y + content.height as i32 - button_height, | |
| 50 | + content.width, | |
| 51 | + button_height as u32, | |
| 52 | + ) | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Sidebar width constant | |
| 56 | + pub fn sidebar_width(&self) -> u32 { | |
| 57 | + SIDEBAR_WIDTH | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Padding constant | |
| 61 | + pub fn padding(&self) -> i32 { | |
| 62 | + PADDING | |
| 63 | + } | |
| 64 | +} | |
gargears/src/ui/mod.rsadded@@ -0,0 +1,10 @@ | ||
| 1 | +//! UI components for gargears | |
| 2 | + | |
| 3 | +mod layout; | |
| 4 | +pub mod panel; | |
| 5 | +mod sidebar; | |
| 6 | +pub mod widgets; | |
| 7 | + | |
| 8 | +pub use layout::Layout; | |
| 9 | +pub use panel::{Panel, PanelAction}; | |
| 10 | +pub use sidebar::Sidebar; | |
gargears/src/ui/panel.rsadded@@ -0,0 +1,81 @@ | ||
| 1 | +//! Panel trait for configuration panels | |
| 2 | + | |
| 3 | +use anyhow::Result; | |
| 4 | +use gartk_core::{InputEvent, Rect, Theme}; | |
| 5 | +use gartk_render::Renderer; | |
| 6 | + | |
| 7 | +/// Actions that a panel can request | |
| 8 | +#[derive(Debug, Clone)] | |
| 9 | +pub enum PanelAction { | |
| 10 | + /// No action | |
| 11 | + None, | |
| 12 | + /// Apply changes via IPC | |
| 13 | + Apply, | |
| 14 | + /// Reset to original values | |
| 15 | + Reset, | |
| 16 | + /// Request a redraw | |
| 17 | + Redraw, | |
| 18 | + /// Save changes to config file | |
| 19 | + Save, | |
| 20 | + /// Instant apply - apply the most recently changed field immediately | |
| 21 | + InstantApply, | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Trait for configuration panels | |
| 25 | +pub trait Panel { | |
| 26 | + /// Get the panel name (for display) | |
| 27 | + fn name(&self) -> &str; | |
| 28 | + | |
| 29 | + /// Get the component description | |
| 30 | + fn description(&self) -> &str; | |
| 31 | + | |
| 32 | + /// Check if the panel has unsaved changes | |
| 33 | + fn is_dirty(&self) -> bool; | |
| 34 | + | |
| 35 | + /// Render the panel | |
| 36 | + fn render(&mut self, renderer: &mut Renderer, bounds: Rect, theme: &Theme) -> Result<()>; | |
| 37 | + | |
| 38 | + /// Handle an input event | |
| 39 | + fn handle_event(&mut self, event: &InputEvent) -> PanelAction; | |
| 40 | + | |
| 41 | + /// Handle mouse move (for hover states). Returns true if redraw needed. | |
| 42 | + fn on_mouse_move(&mut self, x: i32, y: i32) -> bool; | |
| 43 | + | |
| 44 | + /// Clear focus from any focused widget in this panel | |
| 45 | + fn blur_focused(&mut self) {} | |
| 46 | + | |
| 47 | + /// Reset to original/saved values | |
| 48 | + fn reset(&mut self); | |
| 49 | + | |
| 50 | + /// Apply changes (send IPC commands) | |
| 51 | + fn apply(&mut self) -> Result<()>; | |
| 52 | + | |
| 53 | + /// Update with fresh status from daemon | |
| 54 | + fn update_status(&mut self, status: serde_json::Value); | |
| 55 | + | |
| 56 | + /// Check if this panel has a config file that can be saved | |
| 57 | + fn has_config_file(&self) -> bool { | |
| 58 | + false | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Save current settings to config file | |
| 62 | + fn save_to_config(&mut self) -> Result<()> { | |
| 63 | + Ok(()) | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// Set instant-apply mode | |
| 67 | + fn set_instant_apply(&mut self, _enabled: bool) { | |
| 68 | + // Default: do nothing (panel doesn't support instant apply) | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Check if instant-apply is enabled | |
| 72 | + fn instant_apply_enabled(&self) -> bool { | |
| 73 | + false | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Apply the most recently changed field (for instant-apply mode) | |
| 77 | + fn apply_instant(&mut self) -> Result<()> { | |
| 78 | + // Default: fall back to full apply | |
| 79 | + self.apply() | |
| 80 | + } | |
| 81 | +} | |
gargears/src/ui/widgets/color_picker.rsadded@@ -0,0 +1,242 @@ | ||
| 1 | +//! Color picker widget with hex input and preview | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Color, Key, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// A color picker with hex input and preview square | |
| 9 | +pub struct ColorPicker { | |
| 10 | + pub label: String, | |
| 11 | + pub value: String, | |
| 12 | + pub bounds: Rect, | |
| 13 | + pub state: WidgetState, | |
| 14 | + pub cursor: usize, | |
| 15 | +} | |
| 16 | + | |
| 17 | +impl ColorPicker { | |
| 18 | + /// Create a new color picker | |
| 19 | + pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self { | |
| 20 | + let value = value.into(); | |
| 21 | + let cursor = value.len(); | |
| 22 | + Self { | |
| 23 | + label: label.into(), | |
| 24 | + value, | |
| 25 | + bounds: Rect::new(0, 0, 250, 28), | |
| 26 | + state: WidgetState::Normal, | |
| 27 | + cursor, | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + /// Parse hex color string to Color | |
| 32 | + fn parse_color(&self) -> Option<Color> { | |
| 33 | + let hex = self.value.trim_start_matches('#'); | |
| 34 | + if hex.len() == 6 { | |
| 35 | + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; | |
| 36 | + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; | |
| 37 | + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; | |
| 38 | + Some(Color::from_u8(r, g, b, 0xff)) | |
| 39 | + } else if hex.len() == 8 { | |
| 40 | + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; | |
| 41 | + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; | |
| 42 | + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; | |
| 43 | + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; | |
| 44 | + Some(Color::from_u8(r, g, b, a)) | |
| 45 | + } else { | |
| 46 | + None | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Get input field bounds | |
| 51 | + fn input_bounds(&self) -> Rect { | |
| 52 | + let preview_size = 24; | |
| 53 | + let input_width = 100; | |
| 54 | + Rect::new( | |
| 55 | + self.bounds.x + self.bounds.width as i32 - input_width - preview_size - 8, | |
| 56 | + self.bounds.y + 2, | |
| 57 | + input_width as u32, | |
| 58 | + self.bounds.height - 4, | |
| 59 | + ) | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Get preview square bounds | |
| 63 | + fn preview_bounds(&self) -> Rect { | |
| 64 | + let preview_size = 24; | |
| 65 | + Rect::new( | |
| 66 | + self.bounds.x + self.bounds.width as i32 - preview_size - 2, | |
| 67 | + self.bounds.y + (self.bounds.height as i32 - preview_size) / 2, | |
| 68 | + preview_size as u32, | |
| 69 | + preview_size as u32, | |
| 70 | + ) | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// Clear focus from this widget | |
| 74 | + pub fn blur(&mut self) { | |
| 75 | + if self.state == WidgetState::Focused { | |
| 76 | + self.state = WidgetState::Normal; | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Handle mouse move. Returns true if state changed (needs redraw). | |
| 81 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 82 | + if self.state == WidgetState::Disabled || self.state == WidgetState::Focused { | |
| 83 | + return false; | |
| 84 | + } | |
| 85 | + let old_state = self.state; | |
| 86 | + if point_in_rect(x, y, self.input_bounds()) { | |
| 87 | + self.state = WidgetState::Hovered; | |
| 88 | + } else { | |
| 89 | + self.state = WidgetState::Normal; | |
| 90 | + } | |
| 91 | + old_state != self.state | |
| 92 | + } | |
| 93 | + | |
| 94 | + /// Handle mouse click | |
| 95 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 96 | + if self.state != WidgetState::Disabled && point_in_rect(x, y, self.input_bounds()) { | |
| 97 | + self.state = WidgetState::Focused; | |
| 98 | + WidgetEvent::Focus | |
| 99 | + } else if self.state == WidgetState::Focused { | |
| 100 | + self.state = WidgetState::Normal; | |
| 101 | + WidgetEvent::Blur | |
| 102 | + } else { | |
| 103 | + WidgetEvent::None | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// Handle key press (only when focused) | |
| 108 | + pub fn on_key(&mut self, key: &Key) -> WidgetEvent { | |
| 109 | + if self.state != WidgetState::Focused { | |
| 110 | + return WidgetEvent::None; | |
| 111 | + } | |
| 112 | + | |
| 113 | + match key { | |
| 114 | + Key::Char(c) => { | |
| 115 | + // Only allow hex characters and # | |
| 116 | + if c.is_ascii_hexdigit() || *c == '#' { | |
| 117 | + self.value.insert(self.cursor, *c); | |
| 118 | + self.cursor += 1; | |
| 119 | + WidgetEvent::Changed | |
| 120 | + } else { | |
| 121 | + WidgetEvent::None | |
| 122 | + } | |
| 123 | + } | |
| 124 | + Key::Backspace => { | |
| 125 | + if self.cursor > 0 { | |
| 126 | + self.value.remove(self.cursor - 1); | |
| 127 | + self.cursor -= 1; | |
| 128 | + WidgetEvent::Changed | |
| 129 | + } else { | |
| 130 | + WidgetEvent::None | |
| 131 | + } | |
| 132 | + } | |
| 133 | + Key::Delete => { | |
| 134 | + if self.cursor < self.value.len() { | |
| 135 | + self.value.remove(self.cursor); | |
| 136 | + WidgetEvent::Changed | |
| 137 | + } else { | |
| 138 | + WidgetEvent::None | |
| 139 | + } | |
| 140 | + } | |
| 141 | + Key::Left => { | |
| 142 | + if self.cursor > 0 { | |
| 143 | + self.cursor -= 1; | |
| 144 | + } | |
| 145 | + WidgetEvent::None | |
| 146 | + } | |
| 147 | + Key::Right => { | |
| 148 | + if self.cursor < self.value.len() { | |
| 149 | + self.cursor += 1; | |
| 150 | + } | |
| 151 | + WidgetEvent::None | |
| 152 | + } | |
| 153 | + Key::Home => { | |
| 154 | + self.cursor = 0; | |
| 155 | + WidgetEvent::None | |
| 156 | + } | |
| 157 | + Key::End => { | |
| 158 | + self.cursor = self.value.len(); | |
| 159 | + WidgetEvent::None | |
| 160 | + } | |
| 161 | + Key::Escape | Key::Return => { | |
| 162 | + self.state = WidgetState::Normal; | |
| 163 | + WidgetEvent::Blur | |
| 164 | + } | |
| 165 | + _ => WidgetEvent::None, | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Render the color picker | |
| 170 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 171 | + // Draw label | |
| 172 | + let label_style = TextStyle::new() | |
| 173 | + .font_family(&theme.font_family) | |
| 174 | + .font_size(theme.font_size) | |
| 175 | + .color(theme.foreground); | |
| 176 | + | |
| 177 | + let label_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 178 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 179 | + | |
| 180 | + // Draw input field | |
| 181 | + let input = self.input_bounds(); | |
| 182 | + | |
| 183 | + let bg_color = match self.state { | |
| 184 | + WidgetState::Focused => theme.input_background, | |
| 185 | + WidgetState::Hovered => theme.item_hover_background, | |
| 186 | + _ => theme.input_background, | |
| 187 | + }; | |
| 188 | + | |
| 189 | + renderer.fill_rounded_rect(input, 4.0, bg_color)?; | |
| 190 | + | |
| 191 | + let border_color = if self.state == WidgetState::Focused { | |
| 192 | + theme.input_cursor | |
| 193 | + } else { | |
| 194 | + theme.border | |
| 195 | + }; | |
| 196 | + renderer.stroke_rounded_rect(input, 4.0, border_color, 1.0)?; | |
| 197 | + | |
| 198 | + // Draw value | |
| 199 | + let text_style = TextStyle::new() | |
| 200 | + .font_family(&theme.font_family) | |
| 201 | + .font_size(theme.font_size) | |
| 202 | + .color(theme.input_foreground) | |
| 203 | + .ellipsize(true) | |
| 204 | + .max_width(input.width as i32 - 12); | |
| 205 | + | |
| 206 | + let text_x = input.x + 6; | |
| 207 | + let text_y = input.y + (input.height as i32 - theme.font_size as i32) / 2; | |
| 208 | + renderer.text(&self.value, text_x as f64, text_y as f64, &text_style)?; | |
| 209 | + | |
| 210 | + // Draw cursor if focused | |
| 211 | + if self.state == WidgetState::Focused { | |
| 212 | + let cursor_text = &self.value[..self.cursor]; | |
| 213 | + let cursor_style = TextStyle::new() | |
| 214 | + .font_family(&theme.font_family) | |
| 215 | + .font_size(theme.font_size); | |
| 216 | + let cursor_size = renderer.measure_text(cursor_text, &cursor_style)?; | |
| 217 | + let cursor_x = text_x + cursor_size.width as i32; | |
| 218 | + | |
| 219 | + renderer.fill_rect( | |
| 220 | + Rect::new(cursor_x, input.y + 4, 2, input.height - 8), | |
| 221 | + theme.input_cursor, | |
| 222 | + )?; | |
| 223 | + } | |
| 224 | + | |
| 225 | + // Draw color preview square | |
| 226 | + let preview = self.preview_bounds(); | |
| 227 | + | |
| 228 | + // Draw checkerboard pattern for transparency indication | |
| 229 | + let checker_color = Color::from_u8(0x80, 0x80, 0x80, 0xff); | |
| 230 | + renderer.fill_rounded_rect(preview, 3.0, checker_color)?; | |
| 231 | + | |
| 232 | + // Draw the actual color | |
| 233 | + if let Some(color) = self.parse_color() { | |
| 234 | + renderer.fill_rounded_rect(preview, 3.0, color)?; | |
| 235 | + } | |
| 236 | + | |
| 237 | + // Draw preview border | |
| 238 | + renderer.stroke_rounded_rect(preview, 3.0, theme.border, 1.0)?; | |
| 239 | + | |
| 240 | + Ok(()) | |
| 241 | + } | |
| 242 | +} | |
gargears/src/ui/widgets/dropdown.rsadded@@ -0,0 +1,346 @@ | ||
| 1 | +//! Dropdown selection widget | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// Dropdown widget height | |
| 9 | +const DROPDOWN_HEIGHT: u32 = 32; | |
| 10 | +/// Item height in dropdown | |
| 11 | +const ITEM_HEIGHT: u32 = 28; | |
| 12 | +/// Max visible items before scrolling | |
| 13 | +const MAX_VISIBLE_ITEMS: usize = 6; | |
| 14 | + | |
| 15 | +/// A dropdown selection widget | |
| 16 | +pub struct Dropdown { | |
| 17 | + pub label: String, | |
| 18 | + pub options: Vec<String>, | |
| 19 | + pub selected_index: usize, | |
| 20 | + pub bounds: Rect, | |
| 21 | + pub state: WidgetState, | |
| 22 | + pub expanded: bool, | |
| 23 | + scroll_offset: usize, | |
| 24 | + hovered_item: Option<usize>, | |
| 25 | +} | |
| 26 | + | |
| 27 | +impl Dropdown { | |
| 28 | + /// Create a new dropdown | |
| 29 | + pub fn new(label: impl Into<String>, options: Vec<String>) -> Self { | |
| 30 | + Self { | |
| 31 | + label: label.into(), | |
| 32 | + options, | |
| 33 | + selected_index: 0, | |
| 34 | + bounds: Rect::new(0, 0, 300, DROPDOWN_HEIGHT), | |
| 35 | + state: WidgetState::Normal, | |
| 36 | + expanded: false, | |
| 37 | + scroll_offset: 0, | |
| 38 | + hovered_item: None, | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// Set initial selection | |
| 43 | + pub fn with_selection(mut self, index: usize) -> Self { | |
| 44 | + if index < self.options.len() { | |
| 45 | + self.selected_index = index; | |
| 46 | + } | |
| 47 | + self | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Get the selected value | |
| 51 | + pub fn selected_value(&self) -> Option<&str> { | |
| 52 | + self.options.get(self.selected_index).map(|s| s.as_str()) | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Set selected by value | |
| 56 | + pub fn set_selected(&mut self, value: &str) { | |
| 57 | + if let Some(idx) = self.options.iter().position(|o| o == value) { | |
| 58 | + self.selected_index = idx; | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Get the dropdown button bounds (clickable area) | |
| 63 | + fn button_bounds(&self) -> Rect { | |
| 64 | + Rect::new( | |
| 65 | + self.bounds.x + self.bounds.width as i32 / 2, | |
| 66 | + self.bounds.y, | |
| 67 | + self.bounds.width / 2, | |
| 68 | + DROPDOWN_HEIGHT, | |
| 69 | + ) | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// Get the expanded dropdown list bounds | |
| 73 | + fn list_bounds(&self) -> Rect { | |
| 74 | + let visible_count = self.options.len().min(MAX_VISIBLE_ITEMS); | |
| 75 | + let list_height = (visible_count as u32 * ITEM_HEIGHT) + 4; // +4 for border | |
| 76 | + Rect::new( | |
| 77 | + self.bounds.x + self.bounds.width as i32 / 2, | |
| 78 | + self.bounds.y + DROPDOWN_HEIGHT as i32, | |
| 79 | + self.bounds.width / 2, | |
| 80 | + list_height, | |
| 81 | + ) | |
| 82 | + } | |
| 83 | + | |
| 84 | + /// Get item bounds for an index (relative to scroll) | |
| 85 | + fn item_bounds(&self, display_index: usize) -> Rect { | |
| 86 | + let list = self.list_bounds(); | |
| 87 | + Rect::new( | |
| 88 | + list.x + 2, | |
| 89 | + list.y + 2 + (display_index as i32 * ITEM_HEIGHT as i32), | |
| 90 | + list.width - 4, | |
| 91 | + ITEM_HEIGHT, | |
| 92 | + ) | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// Handle mouse move | |
| 96 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) { | |
| 97 | + if self.state == WidgetState::Disabled { | |
| 98 | + return; | |
| 99 | + } | |
| 100 | + | |
| 101 | + if self.expanded { | |
| 102 | + // Check if hovering over list items | |
| 103 | + let list = self.list_bounds(); | |
| 104 | + if point_in_rect(x, y, list) { | |
| 105 | + let visible_count = self.options.len().min(MAX_VISIBLE_ITEMS); | |
| 106 | + for i in 0..visible_count { | |
| 107 | + let item = self.item_bounds(i); | |
| 108 | + if point_in_rect(x, y, item) { | |
| 109 | + self.hovered_item = Some(self.scroll_offset + i); | |
| 110 | + self.state = WidgetState::Hovered; | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | + self.hovered_item = None; | |
| 116 | + } | |
| 117 | + | |
| 118 | + // Check button hover | |
| 119 | + if point_in_rect(x, y, self.button_bounds()) { | |
| 120 | + self.state = WidgetState::Hovered; | |
| 121 | + } else if !self.expanded { | |
| 122 | + self.state = WidgetState::Normal; | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// Handle mouse click | |
| 127 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 128 | + if self.state == WidgetState::Disabled { | |
| 129 | + return WidgetEvent::None; | |
| 130 | + } | |
| 131 | + | |
| 132 | + if self.expanded { | |
| 133 | + // Check if clicking on an item | |
| 134 | + if let Some(hovered) = self.hovered_item { | |
| 135 | + if hovered < self.options.len() { | |
| 136 | + self.selected_index = hovered; | |
| 137 | + self.expanded = false; | |
| 138 | + self.hovered_item = None; | |
| 139 | + return WidgetEvent::Changed; | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + // Click outside closes dropdown | |
| 144 | + let list = self.list_bounds(); | |
| 145 | + let button = self.button_bounds(); | |
| 146 | + if !point_in_rect(x, y, list) && !point_in_rect(x, y, button) { | |
| 147 | + self.expanded = false; | |
| 148 | + self.hovered_item = None; | |
| 149 | + return WidgetEvent::None; | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + // Toggle expansion | |
| 154 | + if point_in_rect(x, y, self.button_bounds()) { | |
| 155 | + self.expanded = !self.expanded; | |
| 156 | + if self.expanded { | |
| 157 | + // Scroll to show selected item | |
| 158 | + if self.selected_index >= MAX_VISIBLE_ITEMS { | |
| 159 | + self.scroll_offset = self.selected_index - MAX_VISIBLE_ITEMS + 1; | |
| 160 | + } else { | |
| 161 | + self.scroll_offset = 0; | |
| 162 | + } | |
| 163 | + } else { | |
| 164 | + self.hovered_item = None; | |
| 165 | + } | |
| 166 | + return WidgetEvent::Clicked; | |
| 167 | + } | |
| 168 | + | |
| 169 | + WidgetEvent::None | |
| 170 | + } | |
| 171 | + | |
| 172 | + /// Handle scroll (when expanded) | |
| 173 | + pub fn on_scroll(&mut self, delta: i32) { | |
| 174 | + if !self.expanded || self.options.len() <= MAX_VISIBLE_ITEMS { | |
| 175 | + return; | |
| 176 | + } | |
| 177 | + | |
| 178 | + let max_scroll = self.options.len() - MAX_VISIBLE_ITEMS; | |
| 179 | + if delta < 0 && self.scroll_offset > 0 { | |
| 180 | + self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize); | |
| 181 | + } else if delta > 0 { | |
| 182 | + self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Close the dropdown | |
| 187 | + pub fn close(&mut self) { | |
| 188 | + self.expanded = false; | |
| 189 | + self.hovered_item = None; | |
| 190 | + } | |
| 191 | + | |
| 192 | + /// Render the dropdown | |
| 193 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 194 | + // Draw label | |
| 195 | + let label_style = TextStyle::new() | |
| 196 | + .font_family(&theme.font_family) | |
| 197 | + .font_size(theme.font_size) | |
| 198 | + .color(theme.foreground); | |
| 199 | + | |
| 200 | + let label_y = self.bounds.y + (DROPDOWN_HEIGHT as i32 - theme.font_size as i32) / 2; | |
| 201 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 202 | + | |
| 203 | + // Draw dropdown button | |
| 204 | + let button = self.button_bounds(); | |
| 205 | + | |
| 206 | + // Button background | |
| 207 | + let bg_color = if self.state == WidgetState::Hovered && !self.expanded { | |
| 208 | + theme.item_hover_background | |
| 209 | + } else { | |
| 210 | + theme.input_background | |
| 211 | + }; | |
| 212 | + renderer.fill_rounded_rect(button, 4.0, bg_color)?; | |
| 213 | + renderer.stroke_rounded_rect(button, 4.0, theme.border, 1.0)?; | |
| 214 | + | |
| 215 | + // Selected text | |
| 216 | + let text = self | |
| 217 | + .options | |
| 218 | + .get(self.selected_index) | |
| 219 | + .map(|s| s.as_str()) | |
| 220 | + .unwrap_or("Select..."); | |
| 221 | + | |
| 222 | + let text_style = TextStyle::new() | |
| 223 | + .font_family(&theme.font_family) | |
| 224 | + .font_size(theme.font_size * 0.9) | |
| 225 | + .color(theme.foreground) | |
| 226 | + .ellipsize(true) | |
| 227 | + .max_width(button.width as i32 - 28); | |
| 228 | + | |
| 229 | + renderer.text( | |
| 230 | + text, | |
| 231 | + (button.x + 8) as f64, | |
| 232 | + (button.y + 8) as f64, | |
| 233 | + &text_style, | |
| 234 | + )?; | |
| 235 | + | |
| 236 | + // Dropdown arrow | |
| 237 | + let arrow = if self.expanded { "▲" } else { "▼" }; | |
| 238 | + let arrow_style = TextStyle::new() | |
| 239 | + .font_family(&theme.font_family) | |
| 240 | + .font_size(theme.font_size * 0.75) | |
| 241 | + .color(theme.item_description); | |
| 242 | + | |
| 243 | + renderer.text( | |
| 244 | + arrow, | |
| 245 | + (button.x + button.width as i32 - 18) as f64, | |
| 246 | + (button.y + 10) as f64, | |
| 247 | + &arrow_style, | |
| 248 | + )?; | |
| 249 | + | |
| 250 | + // Draw expanded list | |
| 251 | + if self.expanded { | |
| 252 | + self.render_list(renderer, theme)?; | |
| 253 | + } | |
| 254 | + | |
| 255 | + Ok(()) | |
| 256 | + } | |
| 257 | + | |
| 258 | + /// Render the expanded list | |
| 259 | + fn render_list(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 260 | + let list = self.list_bounds(); | |
| 261 | + | |
| 262 | + // List background | |
| 263 | + renderer.fill_rounded_rect(list, 4.0, theme.background)?; | |
| 264 | + renderer.stroke_rounded_rect(list, 4.0, theme.border, 1.0)?; | |
| 265 | + | |
| 266 | + // Items | |
| 267 | + let visible_count = self.options.len().min(MAX_VISIBLE_ITEMS); | |
| 268 | + for i in 0..visible_count { | |
| 269 | + let actual_index = self.scroll_offset + i; | |
| 270 | + if actual_index >= self.options.len() { | |
| 271 | + break; | |
| 272 | + } | |
| 273 | + | |
| 274 | + let item = self.item_bounds(i); | |
| 275 | + let is_selected = actual_index == self.selected_index; | |
| 276 | + let is_hovered = self.hovered_item == Some(actual_index); | |
| 277 | + | |
| 278 | + // Item background | |
| 279 | + if is_selected { | |
| 280 | + renderer.fill_rounded_rect(item, 2.0, theme.item_selected_background)?; | |
| 281 | + } else if is_hovered { | |
| 282 | + renderer.fill_rounded_rect(item, 2.0, theme.item_hover_background)?; | |
| 283 | + } | |
| 284 | + | |
| 285 | + // Item text | |
| 286 | + let text_color = if is_selected { | |
| 287 | + theme.item_selected_foreground | |
| 288 | + } else { | |
| 289 | + theme.foreground | |
| 290 | + }; | |
| 291 | + | |
| 292 | + let text_style = TextStyle::new() | |
| 293 | + .font_family(&theme.font_family) | |
| 294 | + .font_size(theme.font_size * 0.9) | |
| 295 | + .color(text_color) | |
| 296 | + .ellipsize(true) | |
| 297 | + .max_width(item.width as i32 - 16); | |
| 298 | + | |
| 299 | + renderer.text( | |
| 300 | + &self.options[actual_index], | |
| 301 | + (item.x + 8) as f64, | |
| 302 | + (item.y + 6) as f64, | |
| 303 | + &text_style, | |
| 304 | + )?; | |
| 305 | + } | |
| 306 | + | |
| 307 | + // Scroll indicators | |
| 308 | + if self.options.len() > MAX_VISIBLE_ITEMS { | |
| 309 | + let indicator_style = TextStyle::new() | |
| 310 | + .font_family(&theme.font_family) | |
| 311 | + .font_size(theme.font_size * 0.6) | |
| 312 | + .color(theme.item_description); | |
| 313 | + | |
| 314 | + if self.scroll_offset > 0 { | |
| 315 | + renderer.text( | |
| 316 | + "▲", | |
| 317 | + (list.x + list.width as i32 - 14) as f64, | |
| 318 | + (list.y + 4) as f64, | |
| 319 | + &indicator_style, | |
| 320 | + )?; | |
| 321 | + } | |
| 322 | + | |
| 323 | + let max_scroll = self.options.len() - MAX_VISIBLE_ITEMS; | |
| 324 | + if self.scroll_offset < max_scroll { | |
| 325 | + renderer.text( | |
| 326 | + "▼", | |
| 327 | + (list.x + list.width as i32 - 14) as f64, | |
| 328 | + (list.y + list.height as i32 - 14) as f64, | |
| 329 | + &indicator_style, | |
| 330 | + )?; | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + Ok(()) | |
| 335 | + } | |
| 336 | + | |
| 337 | + /// Get total height including expanded list (for layout purposes) | |
| 338 | + pub fn expanded_height(&self) -> u32 { | |
| 339 | + if self.expanded { | |
| 340 | + let list = self.list_bounds(); | |
| 341 | + DROPDOWN_HEIGHT + list.height | |
| 342 | + } else { | |
| 343 | + DROPDOWN_HEIGHT | |
| 344 | + } | |
| 345 | + } | |
| 346 | +} | |
gargears/src/ui/widgets/label.rsadded@@ -0,0 +1,104 @@ | ||
| 1 | +//! Static text label widget | |
| 2 | + | |
| 3 | +use anyhow::Result; | |
| 4 | +use gartk_core::{Color, Rect, Theme}; | |
| 5 | +use gartk_render::{Renderer, TextStyle}; | |
| 6 | + | |
| 7 | +/// A static text label with optional key-value display | |
| 8 | +pub struct Label { | |
| 9 | + pub label: String, | |
| 10 | + pub text: String, | |
| 11 | + pub bounds: Rect, | |
| 12 | + pub color: Option<Color>, | |
| 13 | + pub font_size_multiplier: f64, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl Label { | |
| 17 | + /// Create a new label | |
| 18 | + pub fn new(label: impl Into<String>, text: impl Into<String>) -> Self { | |
| 19 | + Self { | |
| 20 | + label: label.into(), | |
| 21 | + text: text.into(), | |
| 22 | + bounds: Rect::new(0, 0, 0, 0), | |
| 23 | + color: None, | |
| 24 | + font_size_multiplier: 1.0, | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// Create a label with just text (no key) | |
| 29 | + pub fn text_only(text: impl Into<String>) -> Self { | |
| 30 | + Self { | |
| 31 | + label: String::new(), | |
| 32 | + text: text.into(), | |
| 33 | + bounds: Rect::new(0, 0, 0, 0), | |
| 34 | + color: None, | |
| 35 | + font_size_multiplier: 1.0, | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + /// Set custom color | |
| 40 | + pub fn with_color(mut self, color: Color) -> Self { | |
| 41 | + self.color = Some(color); | |
| 42 | + self | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Set font size multiplier | |
| 46 | + pub fn with_size(mut self, multiplier: f64) -> Self { | |
| 47 | + self.font_size_multiplier = multiplier; | |
| 48 | + self | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Render the label at a specific position | |
| 52 | + pub fn render_at(&self, renderer: &mut Renderer, x: i32, y: i32, theme: &Theme) -> Result<()> { | |
| 53 | + let style = TextStyle::new() | |
| 54 | + .font_family(&theme.font_family) | |
| 55 | + .font_size(theme.font_size * self.font_size_multiplier) | |
| 56 | + .color(self.color.unwrap_or(theme.foreground)); | |
| 57 | + | |
| 58 | + renderer.text(&self.text, x as f64, y as f64, &style)?; | |
| 59 | + Ok(()) | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Render the label using its bounds (with optional label prefix) | |
| 63 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 64 | + let y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 65 | + | |
| 66 | + // Draw label if present | |
| 67 | + if !self.label.is_empty() { | |
| 68 | + let label_style = TextStyle::new() | |
| 69 | + .font_family(&theme.font_family) | |
| 70 | + .font_size(theme.font_size * self.font_size_multiplier) | |
| 71 | + .color(theme.foreground); | |
| 72 | + | |
| 73 | + renderer.text(&self.label, self.bounds.x as f64, y as f64, &label_style)?; | |
| 74 | + | |
| 75 | + // Draw value on the right side | |
| 76 | + let value_style = TextStyle::new() | |
| 77 | + .font_family(&theme.font_family) | |
| 78 | + .font_size(theme.font_size * self.font_size_multiplier) | |
| 79 | + .color(self.color.unwrap_or(theme.item_description)); | |
| 80 | + | |
| 81 | + let value_x = self.bounds.x + self.bounds.width as i32 / 2; | |
| 82 | + renderer.text(&self.text, value_x as f64, y as f64, &value_style)?; | |
| 83 | + } else { | |
| 84 | + let style = TextStyle::new() | |
| 85 | + .font_family(&theme.font_family) | |
| 86 | + .font_size(theme.font_size * self.font_size_multiplier) | |
| 87 | + .color(self.color.unwrap_or(theme.foreground)); | |
| 88 | + | |
| 89 | + renderer.text(&self.text, self.bounds.x as f64, y as f64, &style)?; | |
| 90 | + } | |
| 91 | + | |
| 92 | + Ok(()) | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// Measure the label size | |
| 96 | + pub fn measure(&self, renderer: &Renderer, theme: &Theme) -> Result<(u32, u32)> { | |
| 97 | + let style = TextStyle::new() | |
| 98 | + .font_family(&theme.font_family) | |
| 99 | + .font_size(theme.font_size * self.font_size_multiplier); | |
| 100 | + | |
| 101 | + let size = renderer.measure_text(&self.text, &style)?; | |
| 102 | + Ok((size.width, size.height)) | |
| 103 | + } | |
| 104 | +} | |
gargears/src/ui/widgets/list.rsadded@@ -0,0 +1,150 @@ | ||
| 1 | +//! Read-only list widget for displaying items | |
| 2 | + | |
| 3 | +use anyhow::Result; | |
| 4 | +use gartk_core::{Rect, Theme}; | |
| 5 | +use gartk_render::{Renderer, TextStyle}; | |
| 6 | + | |
| 7 | +/// A read-only list displaying string items | |
| 8 | +pub struct List { | |
| 9 | + pub items: Vec<String>, | |
| 10 | + pub bounds: Rect, | |
| 11 | + pub scroll_offset: i32, | |
| 12 | + pub max_visible: usize, | |
| 13 | +} | |
| 14 | + | |
| 15 | +impl List { | |
| 16 | + /// Create a new list | |
| 17 | + pub fn new() -> Self { | |
| 18 | + Self { | |
| 19 | + items: Vec::new(), | |
| 20 | + bounds: Rect::new(0, 0, 200, 100), | |
| 21 | + scroll_offset: 0, | |
| 22 | + max_visible: 5, | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + /// Set items | |
| 27 | + pub fn with_items(mut self, items: Vec<String>) -> Self { | |
| 28 | + self.items = items; | |
| 29 | + self | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Set max visible items | |
| 33 | + pub fn with_max_visible(mut self, max: usize) -> Self { | |
| 34 | + self.max_visible = max; | |
| 35 | + self | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Update items | |
| 39 | + pub fn set_items(&mut self, items: Vec<String>) { | |
| 40 | + self.items = items; | |
| 41 | + self.scroll_offset = 0; | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Scroll up | |
| 45 | + pub fn scroll_up(&mut self) { | |
| 46 | + if self.scroll_offset > 0 { | |
| 47 | + self.scroll_offset -= 1; | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Scroll down | |
| 52 | + pub fn scroll_down(&mut self) { | |
| 53 | + let max_scroll = (self.items.len() as i32 - self.max_visible as i32).max(0); | |
| 54 | + if self.scroll_offset < max_scroll { | |
| 55 | + self.scroll_offset += 1; | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// Render the list | |
| 60 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 61 | + let item_height = (theme.font_size + 8.0) as i32; | |
| 62 | + let mut y = self.bounds.y; | |
| 63 | + | |
| 64 | + // Draw background | |
| 65 | + renderer.fill_rounded_rect(self.bounds, 4.0, theme.input_background)?; | |
| 66 | + renderer.stroke_rounded_rect(self.bounds, 4.0, theme.border, 1.0)?; | |
| 67 | + | |
| 68 | + let text_style = TextStyle::new() | |
| 69 | + .font_family(&theme.font_family) | |
| 70 | + .font_size(theme.font_size * 0.9) | |
| 71 | + .color(theme.foreground) | |
| 72 | + .ellipsize(true) | |
| 73 | + .max_width(self.bounds.width as i32 - 16); | |
| 74 | + | |
| 75 | + let start = self.scroll_offset as usize; | |
| 76 | + let end = (start + self.max_visible).min(self.items.len()); | |
| 77 | + | |
| 78 | + for item in self.items.iter().skip(start).take(end - start) { | |
| 79 | + if y + item_height > self.bounds.y + self.bounds.height as i32 { | |
| 80 | + break; | |
| 81 | + } | |
| 82 | + renderer.text( | |
| 83 | + item, | |
| 84 | + (self.bounds.x + 8) as f64, | |
| 85 | + (y + 4) as f64, | |
| 86 | + &text_style, | |
| 87 | + )?; | |
| 88 | + y += item_height; | |
| 89 | + } | |
| 90 | + | |
| 91 | + // Draw scroll indicators if needed | |
| 92 | + if self.items.len() > self.max_visible { | |
| 93 | + let indicator_style = TextStyle::new() | |
| 94 | + .font_family(&theme.font_family) | |
| 95 | + .font_size(theme.font_size * 0.75) | |
| 96 | + .color(theme.item_description); | |
| 97 | + | |
| 98 | + if self.scroll_offset > 0 { | |
| 99 | + renderer.text( | |
| 100 | + "▲", | |
| 101 | + (self.bounds.x + self.bounds.width as i32 - 16) as f64, | |
| 102 | + self.bounds.y as f64, | |
| 103 | + &indicator_style, | |
| 104 | + )?; | |
| 105 | + } | |
| 106 | + | |
| 107 | + let max_scroll = (self.items.len() as i32 - self.max_visible as i32).max(0); | |
| 108 | + if self.scroll_offset < max_scroll { | |
| 109 | + renderer.text( | |
| 110 | + "▼", | |
| 111 | + (self.bounds.x + self.bounds.width as i32 - 16) as f64, | |
| 112 | + (self.bounds.y + self.bounds.height as i32 - 16) as f64, | |
| 113 | + &indicator_style, | |
| 114 | + )?; | |
| 115 | + } | |
| 116 | + | |
| 117 | + // Show count | |
| 118 | + let count_text = format!("{}/{}", end, self.items.len()); | |
| 119 | + renderer.text( | |
| 120 | + &count_text, | |
| 121 | + (self.bounds.x + self.bounds.width as i32 - 50) as f64, | |
| 122 | + (self.bounds.y + self.bounds.height as i32 - 16) as f64, | |
| 123 | + &indicator_style, | |
| 124 | + )?; | |
| 125 | + } | |
| 126 | + | |
| 127 | + // Empty state | |
| 128 | + if self.items.is_empty() { | |
| 129 | + let empty_style = TextStyle::new() | |
| 130 | + .font_family(&theme.font_family) | |
| 131 | + .font_size(theme.font_size * 0.9) | |
| 132 | + .color(theme.item_description); | |
| 133 | + | |
| 134 | + renderer.text( | |
| 135 | + "No items", | |
| 136 | + (self.bounds.x + 8) as f64, | |
| 137 | + (self.bounds.y + 4) as f64, | |
| 138 | + &empty_style, | |
| 139 | + )?; | |
| 140 | + } | |
| 141 | + | |
| 142 | + Ok(()) | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +impl Default for List { | |
| 147 | + fn default() -> Self { | |
| 148 | + Self::new() | |
| 149 | + } | |
| 150 | +} | |
gargears/src/ui/widgets/mod.rsadded@@ -0,0 +1,57 @@ | ||
| 1 | +//! Reusable UI widgets for configuration panels | |
| 2 | + | |
| 3 | +mod button; | |
| 4 | +mod color_picker; | |
| 5 | +mod dropdown; | |
| 6 | +mod label; | |
| 7 | +mod list; | |
| 8 | +mod number_input; | |
| 9 | +mod section; | |
| 10 | +mod slider; | |
| 11 | +mod text_input; | |
| 12 | +mod toggle; | |
| 13 | + | |
| 14 | +pub use button::Button; | |
| 15 | +pub use color_picker::ColorPicker; | |
| 16 | +pub use dropdown::Dropdown; | |
| 17 | +pub use label::Label; | |
| 18 | +pub use list::List; | |
| 19 | +pub use number_input::NumberInput; | |
| 20 | +pub use section::Section; | |
| 21 | +pub use slider::Slider; | |
| 22 | +pub use text_input::TextInput; | |
| 23 | +pub use toggle::Toggle; | |
| 24 | + | |
| 25 | +use gartk_core::Rect; | |
| 26 | + | |
| 27 | +/// Common widget state | |
| 28 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 29 | +pub enum WidgetState { | |
| 30 | + Normal, | |
| 31 | + Hovered, | |
| 32 | + Focused, | |
| 33 | + Disabled, | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Result of handling an event | |
| 37 | +#[derive(Debug, Clone)] | |
| 38 | +pub enum WidgetEvent { | |
| 39 | + /// No action needed | |
| 40 | + None, | |
| 41 | + /// Value was changed | |
| 42 | + Changed, | |
| 43 | + /// Widget was clicked/activated | |
| 44 | + Clicked, | |
| 45 | + /// Request focus | |
| 46 | + Focus, | |
| 47 | + /// Release focus | |
| 48 | + Blur, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Check if a point is within a rect | |
| 52 | +pub fn point_in_rect(x: i32, y: i32, rect: Rect) -> bool { | |
| 53 | + x >= rect.x | |
| 54 | + && x < rect.x + rect.width as i32 | |
| 55 | + && y >= rect.y | |
| 56 | + && y < rect.y + rect.height as i32 | |
| 57 | +} | |
gargears/src/ui/widgets/number_input.rsadded@@ -0,0 +1,186 @@ | ||
| 1 | +//! Number input widget with increment/decrement buttons | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Color, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// Number input with +/- buttons | |
| 9 | +pub struct NumberInput { | |
| 10 | + pub label: String, | |
| 11 | + pub value: i32, | |
| 12 | + pub min: i32, | |
| 13 | + pub max: i32, | |
| 14 | + pub step: i32, | |
| 15 | + pub bounds: Rect, | |
| 16 | + pub state: WidgetState, | |
| 17 | + hover_plus: bool, | |
| 18 | + hover_minus: bool, | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl NumberInput { | |
| 22 | + /// Create a new number input | |
| 23 | + pub fn new(label: impl Into<String>, value: i32) -> Self { | |
| 24 | + Self { | |
| 25 | + label: label.into(), | |
| 26 | + value, | |
| 27 | + min: 0, | |
| 28 | + max: 100, | |
| 29 | + step: 1, | |
| 30 | + bounds: Rect::new(0, 0, 250, 28), | |
| 31 | + state: WidgetState::Normal, | |
| 32 | + hover_plus: false, | |
| 33 | + hover_minus: false, | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// Set range | |
| 38 | + pub fn with_range(mut self, min: i32, max: i32) -> Self { | |
| 39 | + self.min = min; | |
| 40 | + self.max = max; | |
| 41 | + self | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Set step | |
| 45 | + pub fn with_step(mut self, step: i32) -> Self { | |
| 46 | + self.step = step; | |
| 47 | + self | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Get bounds for minus button | |
| 51 | + fn minus_bounds(&self) -> Rect { | |
| 52 | + let btn_size = self.bounds.height; | |
| 53 | + Rect::new( | |
| 54 | + self.bounds.x + self.bounds.width as i32 - (btn_size * 2 + 50) as i32, | |
| 55 | + self.bounds.y, | |
| 56 | + btn_size, | |
| 57 | + btn_size, | |
| 58 | + ) | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Get bounds for plus button | |
| 62 | + fn plus_bounds(&self) -> Rect { | |
| 63 | + let btn_size = self.bounds.height; | |
| 64 | + Rect::new( | |
| 65 | + self.bounds.x + self.bounds.width as i32 - btn_size as i32, | |
| 66 | + self.bounds.y, | |
| 67 | + btn_size, | |
| 68 | + btn_size, | |
| 69 | + ) | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// Get bounds for value display | |
| 73 | + fn value_bounds(&self) -> Rect { | |
| 74 | + let btn_size = self.bounds.height; | |
| 75 | + Rect::new( | |
| 76 | + self.bounds.x + self.bounds.width as i32 - (btn_size + 50) as i32, | |
| 77 | + self.bounds.y, | |
| 78 | + 50, | |
| 79 | + btn_size, | |
| 80 | + ) | |
| 81 | + } | |
| 82 | + | |
| 83 | + /// Handle mouse move. Returns true if state changed (needs redraw). | |
| 84 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 85 | + let old_minus = self.hover_minus; | |
| 86 | + let old_plus = self.hover_plus; | |
| 87 | + self.hover_minus = point_in_rect(x, y, self.minus_bounds()); | |
| 88 | + self.hover_plus = point_in_rect(x, y, self.plus_bounds()); | |
| 89 | + old_minus != self.hover_minus || old_plus != self.hover_plus | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Handle mouse click | |
| 93 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 94 | + if self.state == WidgetState::Disabled { | |
| 95 | + return WidgetEvent::None; | |
| 96 | + } | |
| 97 | + | |
| 98 | + if point_in_rect(x, y, self.minus_bounds()) { | |
| 99 | + let new_value = (self.value - self.step).max(self.min); | |
| 100 | + if new_value != self.value { | |
| 101 | + self.value = new_value; | |
| 102 | + return WidgetEvent::Changed; | |
| 103 | + } | |
| 104 | + } else if point_in_rect(x, y, self.plus_bounds()) { | |
| 105 | + let new_value = (self.value + self.step).min(self.max); | |
| 106 | + if new_value != self.value { | |
| 107 | + self.value = new_value; | |
| 108 | + return WidgetEvent::Changed; | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + WidgetEvent::None | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Render the number input | |
| 116 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 117 | + // Draw label | |
| 118 | + let label_style = TextStyle::new() | |
| 119 | + .font_family(&theme.font_family) | |
| 120 | + .font_size(theme.font_size) | |
| 121 | + .color(theme.foreground); | |
| 122 | + | |
| 123 | + let label_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 124 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 125 | + | |
| 126 | + // Draw minus button | |
| 127 | + let minus = self.minus_bounds(); | |
| 128 | + let minus_bg = if self.hover_minus { | |
| 129 | + theme.item_hover_background | |
| 130 | + } else { | |
| 131 | + theme.input_background | |
| 132 | + }; | |
| 133 | + renderer.fill_rounded_rect(minus, 4.0, minus_bg)?; | |
| 134 | + renderer.stroke_rounded_rect(minus, 4.0, theme.border, 1.0)?; | |
| 135 | + | |
| 136 | + let minus_style = TextStyle::new() | |
| 137 | + .font_family(&theme.font_family) | |
| 138 | + .font_size(theme.font_size) | |
| 139 | + .color(if self.value <= self.min { | |
| 140 | + theme.item_description | |
| 141 | + } else { | |
| 142 | + theme.foreground | |
| 143 | + }); | |
| 144 | + let minus_x = minus.x + (minus.width as i32 - 8) / 2; | |
| 145 | + let minus_y = minus.y + (minus.height as i32 - theme.font_size as i32) / 2; | |
| 146 | + renderer.text("-", minus_x as f64, minus_y as f64, &minus_style)?; | |
| 147 | + | |
| 148 | + // Draw value | |
| 149 | + let value_rect = self.value_bounds(); | |
| 150 | + renderer.fill_rounded_rect(value_rect, 4.0, theme.input_background)?; | |
| 151 | + | |
| 152 | + let value_text = self.value.to_string(); | |
| 153 | + let value_style = TextStyle::new() | |
| 154 | + .font_family(&theme.font_family) | |
| 155 | + .font_size(theme.font_size) | |
| 156 | + .color(theme.foreground); | |
| 157 | + let value_size = renderer.measure_text(&value_text, &value_style)?; | |
| 158 | + let value_x = value_rect.x + (value_rect.width as i32 - value_size.width as i32) / 2; | |
| 159 | + let value_y = value_rect.y + (value_rect.height as i32 - value_size.height as i32) / 2; | |
| 160 | + renderer.text(&value_text, value_x as f64, value_y as f64, &value_style)?; | |
| 161 | + | |
| 162 | + // Draw plus button | |
| 163 | + let plus = self.plus_bounds(); | |
| 164 | + let plus_bg = if self.hover_plus { | |
| 165 | + theme.item_hover_background | |
| 166 | + } else { | |
| 167 | + theme.input_background | |
| 168 | + }; | |
| 169 | + renderer.fill_rounded_rect(plus, 4.0, plus_bg)?; | |
| 170 | + renderer.stroke_rounded_rect(plus, 4.0, theme.border, 1.0)?; | |
| 171 | + | |
| 172 | + let plus_style = TextStyle::new() | |
| 173 | + .font_family(&theme.font_family) | |
| 174 | + .font_size(theme.font_size) | |
| 175 | + .color(if self.value >= self.max { | |
| 176 | + theme.item_description | |
| 177 | + } else { | |
| 178 | + theme.foreground | |
| 179 | + }); | |
| 180 | + let plus_x = plus.x + (plus.width as i32 - 8) / 2; | |
| 181 | + let plus_y = plus.y + (plus.height as i32 - theme.font_size as i32) / 2; | |
| 182 | + renderer.text("+", plus_x as f64, plus_y as f64, &plus_style)?; | |
| 183 | + | |
| 184 | + Ok(()) | |
| 185 | + } | |
| 186 | +} | |
gargears/src/ui/widgets/section.rsadded@@ -0,0 +1,88 @@ | ||
| 1 | +//! Section widget for grouping settings | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// A collapsible section header | |
| 9 | +pub struct Section { | |
| 10 | + pub title: String, | |
| 11 | + pub expanded: bool, | |
| 12 | + pub bounds: Rect, | |
| 13 | + hovered: bool, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl Section { | |
| 17 | + /// Create a new section | |
| 18 | + pub fn new(title: impl Into<String>) -> Self { | |
| 19 | + Self { | |
| 20 | + title: title.into(), | |
| 21 | + expanded: true, | |
| 22 | + bounds: Rect::new(0, 0, 200, 32), | |
| 23 | + hovered: false, | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Handle mouse move. Returns true if state changed (needs redraw). | |
| 28 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 29 | + let was_hovered = self.hovered; | |
| 30 | + self.hovered = point_in_rect(x, y, self.bounds); | |
| 31 | + was_hovered != self.hovered | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Handle mouse click | |
| 35 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 36 | + if point_in_rect(x, y, self.bounds) { | |
| 37 | + self.expanded = !self.expanded; | |
| 38 | + WidgetEvent::Changed | |
| 39 | + } else { | |
| 40 | + WidgetEvent::None | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Render the section header | |
| 45 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 46 | + // Draw background on hover | |
| 47 | + if self.hovered { | |
| 48 | + renderer.fill_rounded_rect(self.bounds, 4.0, theme.item_hover_background)?; | |
| 49 | + } | |
| 50 | + | |
| 51 | + // Draw expand/collapse indicator | |
| 52 | + let indicator = if self.expanded { "▼" } else { "▶" }; | |
| 53 | + let indicator_style = TextStyle::new() | |
| 54 | + .font_family(&theme.font_family) | |
| 55 | + .font_size(theme.font_size * 0.75) | |
| 56 | + .color(theme.item_description); | |
| 57 | + | |
| 58 | + let indicator_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 59 | + renderer.text( | |
| 60 | + indicator, | |
| 61 | + (self.bounds.x + 4) as f64, | |
| 62 | + indicator_y as f64, | |
| 63 | + &indicator_style, | |
| 64 | + )?; | |
| 65 | + | |
| 66 | + // Draw title | |
| 67 | + let title_style = TextStyle::new() | |
| 68 | + .font_family(&theme.font_family) | |
| 69 | + .font_size(theme.font_size) | |
| 70 | + .color(theme.foreground); | |
| 71 | + | |
| 72 | + renderer.text( | |
| 73 | + &self.title, | |
| 74 | + (self.bounds.x + 20) as f64, | |
| 75 | + indicator_y as f64, | |
| 76 | + &title_style, | |
| 77 | + )?; | |
| 78 | + | |
| 79 | + // Draw separator line | |
| 80 | + let line_y = self.bounds.y + self.bounds.height as i32 - 1; | |
| 81 | + renderer.fill_rect( | |
| 82 | + Rect::new(self.bounds.x, line_y, self.bounds.width, 1), | |
| 83 | + theme.border, | |
| 84 | + )?; | |
| 85 | + | |
| 86 | + Ok(()) | |
| 87 | + } | |
| 88 | +} | |
gargears/src/ui/widgets/slider.rsadded@@ -0,0 +1,259 @@ | ||
| 1 | +//! Slider widget for numeric value selection | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Color, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// Slider track height | |
| 9 | +const TRACK_HEIGHT: u32 = 6; | |
| 10 | +/// Slider knob radius | |
| 11 | +const KNOB_RADIUS: f64 = 8.0; | |
| 12 | +/// Widget height | |
| 13 | +const SLIDER_HEIGHT: u32 = 32; | |
| 14 | + | |
| 15 | +/// A slider widget for selecting numeric values | |
| 16 | +pub struct Slider { | |
| 17 | + pub label: String, | |
| 18 | + pub value: f64, | |
| 19 | + pub min: f64, | |
| 20 | + pub max: f64, | |
| 21 | + pub step: f64, | |
| 22 | + pub bounds: Rect, | |
| 23 | + pub state: WidgetState, | |
| 24 | + pub show_value: bool, | |
| 25 | + dragging: bool, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl Slider { | |
| 29 | + /// Create a new slider | |
| 30 | + pub fn new(label: impl Into<String>, min: f64, max: f64) -> Self { | |
| 31 | + Self { | |
| 32 | + label: label.into(), | |
| 33 | + value: min, | |
| 34 | + min, | |
| 35 | + max, | |
| 36 | + step: 1.0, | |
| 37 | + bounds: Rect::new(0, 0, 300, SLIDER_HEIGHT), | |
| 38 | + state: WidgetState::Normal, | |
| 39 | + show_value: true, | |
| 40 | + dragging: false, | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Set initial value | |
| 45 | + pub fn with_value(mut self, value: f64) -> Self { | |
| 46 | + self.value = value.clamp(self.min, self.max); | |
| 47 | + self | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Set step size | |
| 51 | + pub fn with_step(mut self, step: f64) -> Self { | |
| 52 | + self.step = step; | |
| 53 | + self | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Enable/disable value display | |
| 57 | + pub fn with_show_value(mut self, show: bool) -> Self { | |
| 58 | + self.show_value = show; | |
| 59 | + self | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Set the value | |
| 63 | + pub fn set_value(&mut self, value: f64) { | |
| 64 | + self.value = value.clamp(self.min, self.max); | |
| 65 | + } | |
| 66 | + | |
| 67 | + /// Get value as integer | |
| 68 | + pub fn value_i32(&self) -> i32 { | |
| 69 | + self.value.round() as i32 | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// Get track bounds (the slidable area) | |
| 73 | + fn track_bounds(&self) -> Rect { | |
| 74 | + let label_width = self.bounds.width / 3; | |
| 75 | + let value_width = if self.show_value { 50 } else { 0 }; | |
| 76 | + let track_width = self.bounds.width - label_width - value_width - 20; | |
| 77 | + | |
| 78 | + let track_y = self.bounds.y + (SLIDER_HEIGHT as i32 - TRACK_HEIGHT as i32) / 2; | |
| 79 | + Rect::new( | |
| 80 | + self.bounds.x + label_width as i32, | |
| 81 | + track_y, | |
| 82 | + track_width, | |
| 83 | + TRACK_HEIGHT, | |
| 84 | + ) | |
| 85 | + } | |
| 86 | + | |
| 87 | + /// Get knob position (x coordinate) | |
| 88 | + fn knob_x(&self) -> f64 { | |
| 89 | + let track = self.track_bounds(); | |
| 90 | + let ratio = (self.value - self.min) / (self.max - self.min); | |
| 91 | + track.x as f64 + (ratio * (track.width as f64 - KNOB_RADIUS * 2.0)) + KNOB_RADIUS | |
| 92 | + } | |
| 93 | + | |
| 94 | + /// Get knob y coordinate | |
| 95 | + fn knob_y(&self) -> f64 { | |
| 96 | + let track = self.track_bounds(); | |
| 97 | + track.y as f64 + TRACK_HEIGHT as f64 / 2.0 | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// Check if point is on/near the knob | |
| 101 | + fn point_on_knob(&self, x: i32, y: i32) -> bool { | |
| 102 | + let knob_x = self.knob_x(); | |
| 103 | + let knob_y = self.knob_y(); | |
| 104 | + let dx = x as f64 - knob_x; | |
| 105 | + let dy = y as f64 - knob_y; | |
| 106 | + (dx * dx + dy * dy).sqrt() <= KNOB_RADIUS + 4.0 | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Convert x position to value | |
| 110 | + fn x_to_value(&self, x: i32) -> f64 { | |
| 111 | + let track = self.track_bounds(); | |
| 112 | + let track_start = track.x as f64 + KNOB_RADIUS; | |
| 113 | + let track_end = track.x as f64 + track.width as f64 - KNOB_RADIUS; | |
| 114 | + let ratio = ((x as f64 - track_start) / (track_end - track_start)).clamp(0.0, 1.0); | |
| 115 | + let raw_value = self.min + ratio * (self.max - self.min); | |
| 116 | + | |
| 117 | + // Snap to step | |
| 118 | + if self.step > 0.0 { | |
| 119 | + let steps = ((raw_value - self.min) / self.step).round(); | |
| 120 | + (self.min + steps * self.step).clamp(self.min, self.max) | |
| 121 | + } else { | |
| 122 | + raw_value.clamp(self.min, self.max) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// Handle mouse move | |
| 127 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 128 | + if self.state == WidgetState::Disabled { | |
| 129 | + return WidgetEvent::None; | |
| 130 | + } | |
| 131 | + | |
| 132 | + if self.dragging { | |
| 133 | + let new_value = self.x_to_value(x); | |
| 134 | + if (new_value - self.value).abs() > f64::EPSILON { | |
| 135 | + self.value = new_value; | |
| 136 | + return WidgetEvent::Changed; | |
| 137 | + } | |
| 138 | + } else { | |
| 139 | + let track = self.track_bounds(); | |
| 140 | + if self.point_on_knob(x, y) || point_in_rect(x, y, track) { | |
| 141 | + self.state = WidgetState::Hovered; | |
| 142 | + } else { | |
| 143 | + self.state = WidgetState::Normal; | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + WidgetEvent::None | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// Handle mouse down | |
| 151 | + pub fn on_mouse_down(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 152 | + if self.state == WidgetState::Disabled { | |
| 153 | + return WidgetEvent::None; | |
| 154 | + } | |
| 155 | + | |
| 156 | + let track = self.track_bounds(); | |
| 157 | + if self.point_on_knob(x, y) { | |
| 158 | + self.dragging = true; | |
| 159 | + self.state = WidgetState::Focused; | |
| 160 | + return WidgetEvent::Focus; | |
| 161 | + } else if point_in_rect(x, y, track) { | |
| 162 | + // Click on track moves knob to that position | |
| 163 | + self.dragging = true; | |
| 164 | + self.state = WidgetState::Focused; | |
| 165 | + let new_value = self.x_to_value(x); | |
| 166 | + if (new_value - self.value).abs() > f64::EPSILON { | |
| 167 | + self.value = new_value; | |
| 168 | + return WidgetEvent::Changed; | |
| 169 | + } | |
| 170 | + return WidgetEvent::Focus; | |
| 171 | + } | |
| 172 | + | |
| 173 | + WidgetEvent::None | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// Handle mouse up | |
| 177 | + pub fn on_mouse_up(&mut self) -> WidgetEvent { | |
| 178 | + if self.dragging { | |
| 179 | + self.dragging = false; | |
| 180 | + self.state = WidgetState::Normal; | |
| 181 | + return WidgetEvent::Blur; | |
| 182 | + } | |
| 183 | + WidgetEvent::None | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Check if currently dragging | |
| 187 | + pub fn is_dragging(&self) -> bool { | |
| 188 | + self.dragging | |
| 189 | + } | |
| 190 | + | |
| 191 | + /// Render the slider | |
| 192 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 193 | + // Draw label | |
| 194 | + let label_style = TextStyle::new() | |
| 195 | + .font_family(&theme.font_family) | |
| 196 | + .font_size(theme.font_size) | |
| 197 | + .color(theme.foreground); | |
| 198 | + | |
| 199 | + let label_y = self.bounds.y + (SLIDER_HEIGHT as i32 - theme.font_size as i32) / 2; | |
| 200 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 201 | + | |
| 202 | + // Draw track | |
| 203 | + let track = self.track_bounds(); | |
| 204 | + | |
| 205 | + // Track background | |
| 206 | + let track_bg = Rect::new( | |
| 207 | + track.x, | |
| 208 | + track.y, | |
| 209 | + track.width, | |
| 210 | + TRACK_HEIGHT, | |
| 211 | + ); | |
| 212 | + renderer.fill_rounded_rect(track_bg, TRACK_HEIGHT as f64 / 2.0, theme.input_background)?; | |
| 213 | + | |
| 214 | + // Filled portion | |
| 215 | + let knob_x = self.knob_x(); | |
| 216 | + let filled_width = (knob_x - track.x as f64) as u32; | |
| 217 | + if filled_width > 0 { | |
| 218 | + let filled = Rect::new(track.x, track.y, filled_width.min(track.width), TRACK_HEIGHT); | |
| 219 | + renderer.fill_rounded_rect(filled, TRACK_HEIGHT as f64 / 2.0, theme.item_selected_background)?; | |
| 220 | + } | |
| 221 | + | |
| 222 | + // Draw knob | |
| 223 | + let knob_color = match self.state { | |
| 224 | + WidgetState::Hovered | WidgetState::Focused => Color::from_u8(0xff, 0xff, 0xff, 0xff), | |
| 225 | + _ => Color::from_u8(0xea, 0xea, 0xea, 0xff), | |
| 226 | + }; | |
| 227 | + | |
| 228 | + renderer.fill_circle(knob_x, self.knob_y(), KNOB_RADIUS, knob_color)?; | |
| 229 | + | |
| 230 | + // Draw subtle shadow around knob using a slightly larger semi-transparent circle | |
| 231 | + renderer.fill_circle( | |
| 232 | + knob_x, | |
| 233 | + self.knob_y(), | |
| 234 | + KNOB_RADIUS + 1.0, | |
| 235 | + Color::from_u8(0x00, 0x00, 0x00, 0x20), | |
| 236 | + )?; | |
| 237 | + // Redraw knob on top | |
| 238 | + renderer.fill_circle(knob_x, self.knob_y(), KNOB_RADIUS, knob_color)?; | |
| 239 | + | |
| 240 | + // Draw value | |
| 241 | + if self.show_value { | |
| 242 | + let value_text = if self.step >= 1.0 { | |
| 243 | + format!("{}", self.value as i32) | |
| 244 | + } else { | |
| 245 | + format!("{:.1}", self.value) | |
| 246 | + }; | |
| 247 | + | |
| 248 | + let value_style = TextStyle::new() | |
| 249 | + .font_family(&theme.font_family) | |
| 250 | + .font_size(theme.font_size * 0.9) | |
| 251 | + .color(theme.foreground); | |
| 252 | + | |
| 253 | + let value_x = track.x + track.width as i32 + 10; | |
| 254 | + renderer.text(&value_text, value_x as f64, label_y as f64, &value_style)?; | |
| 255 | + } | |
| 256 | + | |
| 257 | + Ok(()) | |
| 258 | + } | |
| 259 | +} | |
gargears/src/ui/widgets/text_input.rsadded@@ -0,0 +1,209 @@ | ||
| 1 | +//! Text input widget | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Key, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// A text input field | |
| 9 | +pub struct TextInput { | |
| 10 | + pub label: String, | |
| 11 | + pub value: String, | |
| 12 | + pub placeholder: String, | |
| 13 | + pub bounds: Rect, | |
| 14 | + pub state: WidgetState, | |
| 15 | + pub cursor: usize, | |
| 16 | +} | |
| 17 | + | |
| 18 | +impl TextInput { | |
| 19 | + /// Create a new text input | |
| 20 | + pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self { | |
| 21 | + let value = value.into(); | |
| 22 | + let cursor = value.len(); | |
| 23 | + Self { | |
| 24 | + label: label.into(), | |
| 25 | + value, | |
| 26 | + placeholder: String::new(), | |
| 27 | + bounds: Rect::new(0, 0, 250, 28), | |
| 28 | + state: WidgetState::Normal, | |
| 29 | + cursor, | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// Set placeholder text | |
| 34 | + pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self { | |
| 35 | + self.placeholder = placeholder.into(); | |
| 36 | + self | |
| 37 | + } | |
| 38 | + | |
| 39 | + /// Get input field bounds | |
| 40 | + fn input_bounds(&self) -> Rect { | |
| 41 | + let input_width = 150; | |
| 42 | + Rect::new( | |
| 43 | + self.bounds.x + self.bounds.width as i32 - input_width, | |
| 44 | + self.bounds.y, | |
| 45 | + input_width as u32, | |
| 46 | + self.bounds.height, | |
| 47 | + ) | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Handle mouse move. Returns true if state changed (needs redraw). | |
| 51 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 52 | + if self.state == WidgetState::Disabled || self.state == WidgetState::Focused { | |
| 53 | + return false; | |
| 54 | + } | |
| 55 | + let old_state = self.state; | |
| 56 | + if point_in_rect(x, y, self.input_bounds()) { | |
| 57 | + self.state = WidgetState::Hovered; | |
| 58 | + } else { | |
| 59 | + self.state = WidgetState::Normal; | |
| 60 | + } | |
| 61 | + old_state != self.state | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// Handle mouse click | |
| 65 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 66 | + if self.state != WidgetState::Disabled && point_in_rect(x, y, self.input_bounds()) { | |
| 67 | + self.state = WidgetState::Focused; | |
| 68 | + WidgetEvent::Focus | |
| 69 | + } else if self.state == WidgetState::Focused { | |
| 70 | + self.state = WidgetState::Normal; | |
| 71 | + WidgetEvent::Blur | |
| 72 | + } else { | |
| 73 | + WidgetEvent::None | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Handle key press (only when focused) | |
| 78 | + pub fn on_key(&mut self, key: &Key) -> WidgetEvent { | |
| 79 | + if self.state != WidgetState::Focused { | |
| 80 | + return WidgetEvent::None; | |
| 81 | + } | |
| 82 | + | |
| 83 | + match key { | |
| 84 | + Key::Char(c) => { | |
| 85 | + self.value.insert(self.cursor, *c); | |
| 86 | + self.cursor += 1; | |
| 87 | + WidgetEvent::Changed | |
| 88 | + } | |
| 89 | + Key::Space => { | |
| 90 | + self.value.insert(self.cursor, ' '); | |
| 91 | + self.cursor += 1; | |
| 92 | + WidgetEvent::Changed | |
| 93 | + } | |
| 94 | + Key::Backspace => { | |
| 95 | + if self.cursor > 0 { | |
| 96 | + self.value.remove(self.cursor - 1); | |
| 97 | + self.cursor -= 1; | |
| 98 | + WidgetEvent::Changed | |
| 99 | + } else { | |
| 100 | + WidgetEvent::None | |
| 101 | + } | |
| 102 | + } | |
| 103 | + Key::Delete => { | |
| 104 | + if self.cursor < self.value.len() { | |
| 105 | + self.value.remove(self.cursor); | |
| 106 | + WidgetEvent::Changed | |
| 107 | + } else { | |
| 108 | + WidgetEvent::None | |
| 109 | + } | |
| 110 | + } | |
| 111 | + Key::Left => { | |
| 112 | + if self.cursor > 0 { | |
| 113 | + self.cursor -= 1; | |
| 114 | + } | |
| 115 | + WidgetEvent::None | |
| 116 | + } | |
| 117 | + Key::Right => { | |
| 118 | + if self.cursor < self.value.len() { | |
| 119 | + self.cursor += 1; | |
| 120 | + } | |
| 121 | + WidgetEvent::None | |
| 122 | + } | |
| 123 | + Key::Home => { | |
| 124 | + self.cursor = 0; | |
| 125 | + WidgetEvent::None | |
| 126 | + } | |
| 127 | + Key::End => { | |
| 128 | + self.cursor = self.value.len(); | |
| 129 | + WidgetEvent::None | |
| 130 | + } | |
| 131 | + Key::Escape | Key::Return => { | |
| 132 | + self.state = WidgetState::Normal; | |
| 133 | + WidgetEvent::Blur | |
| 134 | + } | |
| 135 | + _ => WidgetEvent::None, | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + /// Render the text input | |
| 140 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 141 | + // Draw label | |
| 142 | + let label_style = TextStyle::new() | |
| 143 | + .font_family(&theme.font_family) | |
| 144 | + .font_size(theme.font_size) | |
| 145 | + .color(theme.foreground); | |
| 146 | + | |
| 147 | + let label_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 148 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 149 | + | |
| 150 | + // Draw input field | |
| 151 | + let input = self.input_bounds(); | |
| 152 | + | |
| 153 | + let bg_color = match self.state { | |
| 154 | + WidgetState::Focused => theme.input_background, | |
| 155 | + WidgetState::Hovered => theme.item_hover_background, | |
| 156 | + _ => theme.input_background, | |
| 157 | + }; | |
| 158 | + | |
| 159 | + renderer.fill_rounded_rect(input, 4.0, bg_color)?; | |
| 160 | + | |
| 161 | + let border_color = if self.state == WidgetState::Focused { | |
| 162 | + theme.input_cursor | |
| 163 | + } else { | |
| 164 | + theme.border | |
| 165 | + }; | |
| 166 | + renderer.stroke_rounded_rect(input, 4.0, border_color, 1.0)?; | |
| 167 | + | |
| 168 | + // Draw value or placeholder | |
| 169 | + let display_text = if self.value.is_empty() { | |
| 170 | + &self.placeholder | |
| 171 | + } else { | |
| 172 | + &self.value | |
| 173 | + }; | |
| 174 | + | |
| 175 | + let text_color = if self.value.is_empty() { | |
| 176 | + theme.input_placeholder | |
| 177 | + } else { | |
| 178 | + theme.input_foreground | |
| 179 | + }; | |
| 180 | + | |
| 181 | + let text_style = TextStyle::new() | |
| 182 | + .font_family(&theme.font_family) | |
| 183 | + .font_size(theme.font_size) | |
| 184 | + .color(text_color) | |
| 185 | + .ellipsize(true) | |
| 186 | + .max_width(input.width as i32 - 12); | |
| 187 | + | |
| 188 | + let text_x = input.x + 6; | |
| 189 | + let text_y = input.y + (input.height as i32 - theme.font_size as i32) / 2; | |
| 190 | + renderer.text(display_text, text_x as f64, text_y as f64, &text_style)?; | |
| 191 | + | |
| 192 | + // Draw cursor if focused | |
| 193 | + if self.state == WidgetState::Focused { | |
| 194 | + let cursor_text = &self.value[..self.cursor]; | |
| 195 | + let cursor_style = TextStyle::new() | |
| 196 | + .font_family(&theme.font_family) | |
| 197 | + .font_size(theme.font_size); | |
| 198 | + let cursor_size = renderer.measure_text(cursor_text, &cursor_style)?; | |
| 199 | + let cursor_x = text_x + cursor_size.width as i32; | |
| 200 | + | |
| 201 | + renderer.fill_rect( | |
| 202 | + Rect::new(cursor_x, input.y + 4, 2, input.height - 8), | |
| 203 | + theme.input_cursor, | |
| 204 | + )?; | |
| 205 | + } | |
| 206 | + | |
| 207 | + Ok(()) | |
| 208 | + } | |
| 209 | +} | |
gargears/src/ui/widgets/toggle.rsadded@@ -0,0 +1,112 @@ | ||
| 1 | +//! Toggle switch widget | |
| 2 | + | |
| 3 | +use super::{point_in_rect, WidgetEvent, WidgetState}; | |
| 4 | +use anyhow::Result; | |
| 5 | +use gartk_core::{Color, Rect, Theme}; | |
| 6 | +use gartk_render::{Renderer, TextStyle}; | |
| 7 | + | |
| 8 | +/// Toggle width and height | |
| 9 | +const TOGGLE_WIDTH: u32 = 44; | |
| 10 | +const TOGGLE_HEIGHT: u32 = 24; | |
| 11 | + | |
| 12 | +/// A toggle switch with label | |
| 13 | +pub struct Toggle { | |
| 14 | + pub label: String, | |
| 15 | + pub value: bool, | |
| 16 | + pub bounds: Rect, | |
| 17 | + pub state: WidgetState, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Toggle { | |
| 21 | + /// Create a new toggle | |
| 22 | + pub fn new(label: impl Into<String>, value: bool) -> Self { | |
| 23 | + Self { | |
| 24 | + label: label.into(), | |
| 25 | + value, | |
| 26 | + bounds: Rect::new(0, 0, 200, TOGGLE_HEIGHT), | |
| 27 | + state: WidgetState::Normal, | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + /// Get toggle switch bounds (the clickable part) | |
| 32 | + fn switch_bounds(&self) -> Rect { | |
| 33 | + Rect::new( | |
| 34 | + self.bounds.x + self.bounds.width as i32 - TOGGLE_WIDTH as i32, | |
| 35 | + self.bounds.y, | |
| 36 | + TOGGLE_WIDTH, | |
| 37 | + TOGGLE_HEIGHT, | |
| 38 | + ) | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Handle mouse move. Returns true if state changed (needs redraw). | |
| 42 | + pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool { | |
| 43 | + if self.state == WidgetState::Disabled { | |
| 44 | + return false; | |
| 45 | + } | |
| 46 | + let old_state = self.state; | |
| 47 | + if point_in_rect(x, y, self.switch_bounds()) { | |
| 48 | + self.state = WidgetState::Hovered; | |
| 49 | + } else { | |
| 50 | + self.state = WidgetState::Normal; | |
| 51 | + } | |
| 52 | + old_state != self.state | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Handle mouse click | |
| 56 | + pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent { | |
| 57 | + if self.state != WidgetState::Disabled && point_in_rect(x, y, self.switch_bounds()) { | |
| 58 | + self.value = !self.value; | |
| 59 | + WidgetEvent::Changed | |
| 60 | + } else { | |
| 61 | + WidgetEvent::None | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Render the toggle | |
| 66 | + pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> { | |
| 67 | + // Draw label | |
| 68 | + let label_style = TextStyle::new() | |
| 69 | + .font_family(&theme.font_family) | |
| 70 | + .font_size(theme.font_size) | |
| 71 | + .color(theme.foreground); | |
| 72 | + | |
| 73 | + let label_y = self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2; | |
| 74 | + renderer.text(&self.label, self.bounds.x as f64, label_y as f64, &label_style)?; | |
| 75 | + | |
| 76 | + // Draw toggle switch | |
| 77 | + let switch = self.switch_bounds(); | |
| 78 | + | |
| 79 | + // Track background | |
| 80 | + let track_color = if self.value { | |
| 81 | + Color::from_u8(0x50, 0xfa, 0x7b, 0xff) // Green when on | |
| 82 | + } else { | |
| 83 | + Color::from_u8(0x44, 0x47, 0x5a, 0xff) // Gray when off | |
| 84 | + }; | |
| 85 | + | |
| 86 | + renderer.fill_rounded_rect(switch, TOGGLE_HEIGHT as f64 / 2.0, track_color)?; | |
| 87 | + | |
| 88 | + // Knob | |
| 89 | + let knob_padding = 2; | |
| 90 | + let knob_size = TOGGLE_HEIGHT - (knob_padding * 2) as u32; | |
| 91 | + let knob_x = if self.value { | |
| 92 | + switch.x + switch.width as i32 - knob_size as i32 - knob_padding | |
| 93 | + } else { | |
| 94 | + switch.x + knob_padding | |
| 95 | + }; | |
| 96 | + | |
| 97 | + let knob_color = if self.state == WidgetState::Hovered { | |
| 98 | + Color::from_u8(0xff, 0xff, 0xff, 0xff) | |
| 99 | + } else { | |
| 100 | + Color::from_u8(0xea, 0xea, 0xea, 0xff) | |
| 101 | + }; | |
| 102 | + | |
| 103 | + renderer.fill_circle( | |
| 104 | + (knob_x + knob_size as i32 / 2) as f64, | |
| 105 | + (switch.y + TOGGLE_HEIGHT as i32 / 2) as f64, | |
| 106 | + knob_size as f64 / 2.0, | |
| 107 | + knob_color, | |
| 108 | + )?; | |
| 109 | + | |
| 110 | + Ok(()) | |
| 111 | + } | |
| 112 | +} | |
gargearsctl/Cargo.tomladded@@ -0,0 +1,19 @@ | ||
| 1 | +[package] | |
| 2 | +name = "gargearsctl" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +authors.workspace = true | |
| 7 | +description = "CLI control utility for gargears" | |
| 8 | + | |
| 9 | +[[bin]] | |
| 10 | +name = "gargearsctl" | |
| 11 | +path = "src/main.rs" | |
| 12 | + | |
| 13 | +[dependencies] | |
| 14 | +gargears-ipc.workspace = true | |
| 15 | + | |
| 16 | +clap.workspace = true | |
| 17 | +serde.workspace = true | |
| 18 | +serde_json.workspace = true | |
| 19 | +anyhow.workspace = true | |
gargearsctl/src/main.rsadded@@ -0,0 +1,78 @@ | ||
| 1 | +//! CLI control utility for gargears daemon | |
| 2 | + | |
| 3 | +use anyhow::{Context, Result}; | |
| 4 | +use clap::{Parser, Subcommand}; | |
| 5 | +use gargears_ipc::{socket_path, Command, Response}; | |
| 6 | +use std::io::{BufRead, BufReader, Write}; | |
| 7 | +use std::os::unix::net::UnixStream; | |
| 8 | + | |
| 9 | +#[derive(Parser, Debug)] | |
| 10 | +#[command(name = "gargearsctl")] | |
| 11 | +#[command(about = "Control the gargears daemon")] | |
| 12 | +struct Args { | |
| 13 | + #[command(subcommand)] | |
| 14 | + command: CtlCommand, | |
| 15 | +} | |
| 16 | + | |
| 17 | +#[derive(Subcommand, Debug)] | |
| 18 | +enum CtlCommand { | |
| 19 | + /// Show the gargears window | |
| 20 | + Show, | |
| 21 | + /// Hide the gargears window | |
| 22 | + Hide, | |
| 23 | + /// Toggle gargears window visibility | |
| 24 | + Toggle, | |
| 25 | + /// Get daemon status | |
| 26 | + Status, | |
| 27 | + /// Quit the daemon | |
| 28 | + Quit, | |
| 29 | +} | |
| 30 | + | |
| 31 | +fn main() -> Result<()> { | |
| 32 | + let args = Args::parse(); | |
| 33 | + | |
| 34 | + let command = match args.command { | |
| 35 | + CtlCommand::Show => Command::Show, | |
| 36 | + CtlCommand::Hide => Command::Hide, | |
| 37 | + CtlCommand::Toggle => Command::Toggle, | |
| 38 | + CtlCommand::Status => Command::Status, | |
| 39 | + CtlCommand::Quit => Command::Quit, | |
| 40 | + }; | |
| 41 | + | |
| 42 | + let response = send_command(&command)?; | |
| 43 | + | |
| 44 | + if response.success { | |
| 45 | + if let Some(data) = response.data { | |
| 46 | + println!("{}", serde_json::to_string_pretty(&data)?); | |
| 47 | + } else { | |
| 48 | + println!("OK"); | |
| 49 | + } | |
| 50 | + } else { | |
| 51 | + eprintln!("Error: {}", response.error.unwrap_or_else(|| "Unknown error".into())); | |
| 52 | + std::process::exit(1); | |
| 53 | + } | |
| 54 | + | |
| 55 | + Ok(()) | |
| 56 | +} | |
| 57 | + | |
| 58 | +fn send_command(command: &Command) -> Result<Response> { | |
| 59 | + let path = socket_path(); | |
| 60 | + | |
| 61 | + let mut stream = UnixStream::connect(&path) | |
| 62 | + .with_context(|| format!("Failed to connect to gargears daemon at {:?}", path))?; | |
| 63 | + | |
| 64 | + // Send command | |
| 65 | + let json = serde_json::to_string(command)?; | |
| 66 | + writeln!(stream, "{}", json)?; | |
| 67 | + stream.flush()?; | |
| 68 | + | |
| 69 | + // Read response | |
| 70 | + let mut reader = BufReader::new(stream); | |
| 71 | + let mut line = String::new(); | |
| 72 | + reader.read_line(&mut line)?; | |
| 73 | + | |
| 74 | + let response: Response = serde_json::from_str(&line) | |
| 75 | + .with_context(|| "Failed to parse daemon response")?; | |
| 76 | + | |
| 77 | + Ok(response) | |
| 78 | +} | |