gardesk/gargears / a6b7cdc

Browse files

initial commit: configuration GUI for gar desktop

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a6b7cdc6d147839da863f26f2df5dfb0a003f2a1
Tree
fc88a40

60 changed files

StatusFile+-
A .gitignore 15 0
A Cargo.toml 39 0
A docs/.fackr/workspace.json 29 0
A docs/gargears.md 3 0
A gargears-ipc/Cargo.toml 12 0
A gargears-ipc/src/lib.rs 73 0
A gargears/Cargo.toml 29 0
A gargears/src/app.rs 933 0
A gargears/src/config/lua_writer.rs 364 0
A gargears/src/config/mod.rs 77 0
A gargears/src/config/toml_writer.rs 276 0
A gargears/src/daemon.rs 165 0
A gargears/src/ipc/adapter.rs 50 0
A gargears/src/ipc/adapters/gar.rs 288 0
A gargears/src/ipc/adapters/garbar.rs 141 0
A gargears/src/ipc/adapters/garbg.rs 211 0
A gargears/src/ipc/adapters/garclip.rs 111 0
A gargears/src/ipc/adapters/garfield.rs 103 0
A gargears/src/ipc/adapters/garlaunch.rs 105 0
A gargears/src/ipc/adapters/garlock.rs 95 0
A gargears/src/ipc/adapters/garnotify.rs 121 0
A gargears/src/ipc/adapters/garshot.rs 92 0
A gargears/src/ipc/adapters/garterm.rs 155 0
A gargears/src/ipc/adapters/gartray.rs 107 0
A gargears/src/ipc/adapters/mod.rs 25 0
A gargears/src/ipc/client.rs 230 0
A gargears/src/ipc/discovery.rs 56 0
A gargears/src/ipc/manager.rs 384 0
A gargears/src/ipc/mod.rs 10 0
A gargears/src/main.rs 91 0
A gargears/src/panels/gar.rs 759 0
A gargears/src/panels/garbar.rs 524 0
A gargears/src/panels/garbg.rs 541 0
A gargears/src/panels/garclip.rs 279 0
A gargears/src/panels/garfield.rs 260 0
A gargears/src/panels/garlaunch.rs 247 0
A gargears/src/panels/garlock.rs 326 0
A gargears/src/panels/garnotify.rs 342 0
A gargears/src/panels/garshot.rs 256 0
A gargears/src/panels/garterm.rs 294 0
A gargears/src/panels/gartray.rs 351 0
A gargears/src/panels/mod.rs 116 0
A gargears/src/panels/placeholder.rs 123 0
A gargears/src/ui/layout.rs 64 0
A gargears/src/ui/mod.rs 10 0
A gargears/src/ui/panel.rs 81 0
A gargears/src/ui/sidebar.rs 135 0
A gargears/src/ui/widgets/button.rs 112 0
A gargears/src/ui/widgets/color_picker.rs 242 0
A gargears/src/ui/widgets/dropdown.rs 346 0
A gargears/src/ui/widgets/label.rs 104 0
A gargears/src/ui/widgets/list.rs 150 0
A gargears/src/ui/widgets/mod.rs 57 0
A gargears/src/ui/widgets/number_input.rs 186 0
A gargears/src/ui/widgets/section.rs 88 0
A gargears/src/ui/widgets/slider.rs 259 0
A gargears/src/ui/widgets/text_input.rs 209 0
A gargears/src/ui/widgets/toggle.rs 112 0
A gargearsctl/Cargo.toml 19 0
A gargearsctl/src/main.rs 78 0
.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", &current_source),
74
+            current_wallpaper: Label::new("File", &current_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", &current_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/sidebar.rsadded
@@ -0,0 +1,135 @@
1
+//! Sidebar navigation component
2
+
3
+use crate::ipc::discovery::DaemonStatus;
4
+use crate::panels::Component;
5
+use crate::ui::Layout;
6
+use anyhow::Result;
7
+use gartk_core::{Color, Rect, Theme};
8
+use gartk_render::{Renderer, TextStyle};
9
+
10
+/// Item height in sidebar
11
+const ITEM_HEIGHT: i32 = 36;
12
+/// Status indicator radius
13
+const STATUS_RADIUS: f64 = 4.0;
14
+
15
+/// Sidebar navigation
16
+pub struct Sidebar {
17
+    hovered: Option<Component>,
18
+}
19
+
20
+impl Sidebar {
21
+    /// Create a new sidebar
22
+    pub fn new() -> Self {
23
+        Self { hovered: None }
24
+    }
25
+
26
+    /// Get the component at a given y position
27
+    pub fn component_at_y(
28
+        &self,
29
+        y: i32,
30
+        layout: &Layout,
31
+        theme: &Theme,
32
+    ) -> Option<Component> {
33
+        let bounds = layout.sidebar_bounds();
34
+        let start_y = bounds.y + theme.padding as i32;
35
+
36
+        let components = Component::all();
37
+        for (i, component) in components.iter().enumerate() {
38
+            let item_y = start_y + (i as i32 * ITEM_HEIGHT);
39
+            if y >= item_y && y < item_y + ITEM_HEIGHT {
40
+                return Some(*component);
41
+            }
42
+        }
43
+        None
44
+    }
45
+
46
+    /// Render the sidebar
47
+    pub fn render(
48
+        &self,
49
+        renderer: &mut Renderer,
50
+        layout: &Layout,
51
+        theme: &Theme,
52
+        selected: Component,
53
+        daemon_status: &[DaemonStatus],
54
+    ) -> Result<()> {
55
+        let bounds = layout.sidebar_bounds();
56
+
57
+        // Draw sidebar background
58
+        renderer.fill_rounded_rect(bounds, theme.border_radius, theme.item_background)?;
59
+
60
+        // Draw title
61
+        let title_style = TextStyle::new()
62
+            .font_family(&theme.font_family)
63
+            .font_size(theme.font_size * 1.2)
64
+            .color(theme.foreground);
65
+
66
+        renderer.text(
67
+            "Components",
68
+            (bounds.x + theme.padding as i32) as f64,
69
+            (bounds.y + theme.padding as i32) as f64,
70
+            &title_style,
71
+        )?;
72
+
73
+        // Draw components
74
+        let start_y = bounds.y + theme.padding as i32 + theme.font_size as i32 + 16;
75
+        let components = Component::all();
76
+
77
+        for (i, component) in components.iter().enumerate() {
78
+            let item_y = start_y + (i as i32 * ITEM_HEIGHT);
79
+            let item_rect = Rect::new(
80
+                bounds.x + 4,
81
+                item_y,
82
+                bounds.width - 8,
83
+                ITEM_HEIGHT as u32,
84
+            );
85
+
86
+            // Highlight selected
87
+            if *component == selected {
88
+                renderer.fill_rounded_rect(
89
+                    item_rect,
90
+                    theme.border_radius / 2.0,
91
+                    theme.item_selected_background,
92
+                )?;
93
+            }
94
+
95
+            // Draw status indicator
96
+            let status = daemon_status.iter().find(|s| s.component == *component);
97
+            let indicator_color = match status {
98
+                Some(s) if s.running => Color::from_u8(0x50, 0xfa, 0x7b, 0xff), // Green
99
+                _ => Color::from_u8(0x6c, 0x75, 0x7d, 0xff),                     // Gray
100
+            };
101
+
102
+            let indicator_x = bounds.x + theme.padding as i32 + STATUS_RADIUS as i32;
103
+            let indicator_y = item_y + (ITEM_HEIGHT / 2);
104
+
105
+            renderer.fill_circle(
106
+                indicator_x as f64,
107
+                indicator_y as f64,
108
+                STATUS_RADIUS,
109
+                indicator_color,
110
+            )?;
111
+
112
+            // Draw component name
113
+            let name_style = TextStyle::new()
114
+                .font_family(&theme.font_family)
115
+                .font_size(theme.font_size)
116
+                .color(if *component == selected {
117
+                    theme.item_selected_foreground
118
+                } else {
119
+                    theme.item_foreground
120
+                });
121
+
122
+            let text_x = bounds.x + theme.padding as i32 + (STATUS_RADIUS * 2.0) as i32 + 12;
123
+            let text_y = item_y + (ITEM_HEIGHT - theme.font_size as i32) / 2;
124
+
125
+            renderer.text(
126
+                component.display_name(),
127
+                text_x as f64,
128
+                text_y as f64,
129
+                &name_style,
130
+            )?;
131
+        }
132
+
133
+        Ok(())
134
+    }
135
+}
gargears/src/ui/widgets/button.rsadded
@@ -0,0 +1,112 @@
1
+//! Clickable button 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
+/// A clickable button
9
+pub struct Button {
10
+    pub label: String,
11
+    pub bounds: Rect,
12
+    pub state: WidgetState,
13
+    pub primary: bool,
14
+}
15
+
16
+impl Button {
17
+    /// Create a new button
18
+    pub fn new(label: impl Into<String>) -> Self {
19
+        Self {
20
+            label: label.into(),
21
+            bounds: Rect::new(0, 0, 100, 32),
22
+            state: WidgetState::Normal,
23
+            primary: false,
24
+        }
25
+    }
26
+
27
+    /// Mark as primary (highlighted) button
28
+    pub fn primary(mut self) -> Self {
29
+        self.primary = true;
30
+        self
31
+    }
32
+
33
+    /// Set bounds
34
+    pub fn with_bounds(mut self, bounds: Rect) -> Self {
35
+        self.bounds = bounds;
36
+        self
37
+    }
38
+
39
+    /// Handle mouse move. Returns true if state changed (needs redraw).
40
+    pub fn on_mouse_move(&mut self, x: i32, y: i32) -> bool {
41
+        if self.state == WidgetState::Disabled {
42
+            return false;
43
+        }
44
+        let old_state = self.state;
45
+        if point_in_rect(x, y, self.bounds) {
46
+            self.state = WidgetState::Hovered;
47
+        } else {
48
+            self.state = WidgetState::Normal;
49
+        }
50
+        old_state != self.state
51
+    }
52
+
53
+    /// Handle mouse click
54
+    pub fn on_click(&mut self, x: i32, y: i32) -> WidgetEvent {
55
+        if self.state != WidgetState::Disabled && point_in_rect(x, y, self.bounds) {
56
+            WidgetEvent::Clicked
57
+        } else {
58
+            WidgetEvent::None
59
+        }
60
+    }
61
+
62
+    /// Render the button
63
+    pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> Result<()> {
64
+        // Background color based on state
65
+        let bg_color = match self.state {
66
+            WidgetState::Hovered => {
67
+                if self.primary {
68
+                    Color::from_u8(0x6c, 0xa4, 0xf8, 0xff) // Lighter primary
69
+                } else {
70
+                    theme.item_hover_background
71
+                }
72
+            }
73
+            WidgetState::Disabled => Color::from_u8(0x40, 0x40, 0x40, 0xff),
74
+            _ => {
75
+                if self.primary {
76
+                    Color::from_u8(0x5c, 0x94, 0xe8, 0xff) // Primary blue
77
+                } else {
78
+                    theme.input_background
79
+                }
80
+            }
81
+        };
82
+
83
+        // Draw background
84
+        renderer.fill_rounded_rect(self.bounds, theme.border_radius / 2.0, bg_color)?;
85
+
86
+        // Draw border
87
+        renderer.stroke_rounded_rect(
88
+            self.bounds,
89
+            theme.border_radius / 2.0,
90
+            theme.border,
91
+            1.0,
92
+        )?;
93
+
94
+        // Draw text centered
95
+        let text_style = TextStyle::new()
96
+            .font_family(&theme.font_family)
97
+            .font_size(theme.font_size)
98
+            .color(if self.state == WidgetState::Disabled {
99
+                theme.item_description
100
+            } else {
101
+                theme.foreground
102
+            });
103
+
104
+        let text_size = renderer.measure_text(&self.label, &text_style)?;
105
+        let text_x = self.bounds.x + (self.bounds.width as i32 - text_size.width as i32) / 2;
106
+        let text_y = self.bounds.y + (self.bounds.height as i32 - text_size.height as i32) / 2;
107
+
108
+        renderer.text(&self.label, text_x as f64, text_y as f64, &text_style)?;
109
+
110
+        Ok(())
111
+    }
112
+}
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
+}