gardesk/garbg / 4d4352c

Browse files

Add systemd Type=notify support and multi-monitor compositing

Session startup:
- Add sd-notify for READY=1 signaling when socket is listening
- Remove retry hacks (connect_with_retry, send_command_with_retry)
- Remove X11 health check timer (PartOf=gar-session.target handles lifecycle)
- Daemon now fails fast if X11 unavailable - systemd handles ordering

Multi-monitor support (Phase 5.3):
- Add RandR-based monitor detection via Monitor::get_all()
- Add Compositor for multi-monitor wallpaper compositing
- Support set-monitor command for per-monitor wallpapers
- Handle monitor hotplug events from gar

Enhanced IPC:
- Add QueryMonitors, QueryCurrent, SetMonitor commands
- Add monitor event subscription to gar client

This enables race-condition-free startup when used with
gar-session.target and Type=notify systemd service.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4d4352c7c0824fa5b6344c837d36f7a92e062aaa
Parents
5bc1809
Tree
b8ab955

10 changed files

StatusFile+-
M Cargo.lock 10 0
A docs/gar-integration.md 264 0
M garbg/Cargo.toml 1 0
M garbg/src/daemon/state.rs 336 14
M garbg/src/ipc/gar_client.rs 98 0
M garbg/src/ipc/protocol.rs 16 0
M garbg/src/main.rs 196 25
A garbg/src/x11/compositor.rs 177 0
M garbg/src/x11/mod.rs 2 0
M garbg/src/x11/monitors.rs 97 6
Cargo.lockmodified
@@ -1346,6 +1346,7 @@ dependencies = [
13461346
  "rand 0.8.5",
13471347
  "reqwest",
13481348
  "scraper",
1349
+ "sd-notify",
13491350
  "serde",
13501351
  "serde_json",
13511352
  "shellexpand",
@@ -2851,6 +2852,15 @@ dependencies = [
28512852
  "untrusted",
28522853
 ]
28532854
 
2855
+[[package]]
2856
+name = "sd-notify"
2857
+version = "0.4.5"
2858
+source = "registry+https://github.com/rust-lang/crates.io-index"
2859
+checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4"
2860
+dependencies = [
2861
+ "libc",
2862
+]
2863
+
28542864
 [[package]]
28552865
 name = "sec1"
28562866
 version = "0.3.0"
docs/gar-integration.mdadded
@@ -0,0 +1,264 @@
1
+# garbg Integration with gar Window Manager
2
+
3
+This guide explains how to integrate garbg (wallpaper daemon) with the gar tiling window manager.
4
+
5
+## Quick Start
6
+
7
+### 1. Start garbg daemon on login
8
+
9
+Add to your `~/.config/gar/init.lua`:
10
+
11
+```lua
12
+-- Start garbg daemon on gar startup
13
+gar.exec_once("garbg daemon")
14
+```
15
+
16
+Or if using a shell startup script (`~/.xinitrc`, `~/.xprofile`):
17
+
18
+```bash
19
+garbg daemon &
20
+```
21
+
22
+### 2. Set initial wallpaper
23
+
24
+In your gar config:
25
+
26
+```lua
27
+-- Set wallpaper after daemon starts
28
+gar.exec_once("sleep 0.5 && garbg set ~/Pictures/wallpaper.jpg")
29
+
30
+-- Or set a slideshow directory
31
+gar.exec_once("sleep 0.5 && garbg set ~/Pictures/wallpapers --random --interval 5m")
32
+```
33
+
34
+## Keybindings
35
+
36
+Add wallpaper control keybindings to your gar config:
37
+
38
+```lua
39
+-- Wallpaper navigation
40
+gar.bind("mod+bracketright", function() gar.exec("garbg next") end)     -- Mod+]
41
+gar.bind("mod+bracketleft", function() gar.exec("garbg prev") end)      -- Mod+[
42
+gar.bind("mod+shift+w", function() gar.exec("garbg random") end)        -- Mod+Shift+W
43
+
44
+-- Pause/resume slideshow or animation
45
+gar.bind("mod+shift+p", function() gar.exec("garbg toggle") end)        -- Mod+Shift+P
46
+```
47
+
48
+## Helper Functions
49
+
50
+Create reusable functions in your gar config:
51
+
52
+```lua
53
+-- Wallpaper helper module
54
+garbg = {
55
+    next = function() gar.exec("garbg next") end,
56
+    prev = function() gar.exec("garbg prev") end,
57
+    pause = function() gar.exec("garbg pause") end,
58
+    resume = function() gar.exec("garbg resume") end,
59
+    toggle = function() gar.exec("garbg toggle") end,
60
+    random = function() gar.exec("garbg random") end,
61
+
62
+    set = function(source, opts)
63
+        local cmd = "garbg set '" .. source .. "'"
64
+        if opts then
65
+            if opts.mode then cmd = cmd .. " --mode " .. opts.mode end
66
+            if opts.random then cmd = cmd .. " --random" end
67
+            if opts.interval then cmd = cmd .. " --interval " .. opts.interval end
68
+            if opts.animate then cmd = cmd .. " --animate" end
69
+        end
70
+        gar.exec(cmd)
71
+    end,
72
+
73
+    set_monitor = function(monitor, source, mode)
74
+        local cmd = "garbg set-monitor " .. monitor .. " '" .. source .. "'"
75
+        if mode then cmd = cmd .. " --mode " .. mode end
76
+        gar.exec(cmd)
77
+    end,
78
+}
79
+
80
+-- Usage examples:
81
+-- garbg.set("~/Pictures/wallpapers", { random = true, interval = "5m" })
82
+-- garbg.set_monitor("DP-1", "~/left.jpg")
83
+-- garbg.toggle()
84
+```
85
+
86
+## Per-Workspace Wallpapers
87
+
88
+garbg automatically subscribes to gar workspace events. Configure per-workspace wallpapers in `~/.config/garbg/config.toml`:
89
+
90
+```toml
91
+[general]
92
+mode = "fill"
93
+
94
+[default]
95
+source = "~/Pictures/default.jpg"
96
+
97
+[[workspaces]]
98
+id = 1
99
+source = "~/Pictures/workspace1.jpg"
100
+
101
+[[workspaces]]
102
+id = 2
103
+source = "~/Pictures/workspace2.jpg"
104
+
105
+[[workspaces]]
106
+id = 9
107
+source = "~/Pictures/gaming.jpg"
108
+```
109
+
110
+When you switch workspaces in gar, garbg will automatically change the wallpaper.
111
+
112
+## Multi-Monitor Setup
113
+
114
+### Query available monitors
115
+
116
+```bash
117
+garbg query monitors
118
+```
119
+
120
+Output:
121
+```json
122
+{
123
+  "monitors": [
124
+    {"name": "DP-1", "width": 2560, "height": 1440, "x": 0, "y": 0, "primary": true},
125
+    {"name": "HDMI-1", "width": 1920, "height": 1080, "x": 2560, "y": 180, "primary": false}
126
+  ]
127
+}
128
+```
129
+
130
+### Set per-monitor wallpapers
131
+
132
+```bash
133
+# Set different wallpapers for each monitor
134
+garbg set-monitor DP-1 ~/Pictures/left.jpg
135
+garbg set-monitor HDMI-1 ~/Pictures/right.jpg
136
+```
137
+
138
+In gar config:
139
+```lua
140
+gar.exec_once("sleep 0.5 && garbg set-monitor DP-1 ~/Pictures/main.jpg")
141
+gar.exec_once("sleep 0.5 && garbg set-monitor HDMI-1 ~/Pictures/side.jpg")
142
+```
143
+
144
+## Monitor Hotplug
145
+
146
+garbg subscribes to monitor events from gar. When a monitor is:
147
+- **Added**: Wallpaper is automatically applied to the new monitor
148
+- **Removed**: Monitor state is cleaned up
149
+- **Changed**: Wallpapers are re-composited at new positions/resolutions
150
+
151
+No additional configuration needed - this works automatically when connected to gar.
152
+
153
+## Animated Wallpapers
154
+
155
+### Animated GIFs
156
+
157
+```bash
158
+garbg set ~/Pictures/animated.gif --animate
159
+```
160
+
161
+### Video wallpapers (requires `video` feature)
162
+
163
+```bash
164
+garbg set ~/Videos/loop.mp4
165
+# Videos are automatically animated (no --animate flag needed)
166
+```
167
+
168
+## IPC Commands
169
+
170
+All commands can be sent to the daemon via the CLI:
171
+
172
+| Command | Description |
173
+|---------|-------------|
174
+| `garbg set <source>` | Set wallpaper |
175
+| `garbg set-monitor <name> <source>` | Set per-monitor wallpaper |
176
+| `garbg next` | Next in slideshow |
177
+| `garbg prev` | Previous in slideshow |
178
+| `garbg random` | Random from playlist |
179
+| `garbg pause` | Pause slideshow/animation |
180
+| `garbg resume` | Resume slideshow/animation |
181
+| `garbg toggle` | Toggle pause state |
182
+| `garbg status` | Get current status (JSON) |
183
+| `garbg query monitors` | List monitors (JSON) |
184
+| `garbg query current` | Current wallpaper info (JSON) |
185
+| `garbg reload` | Reload configuration |
186
+
187
+## Troubleshooting
188
+
189
+### garbg not connecting to gar
190
+
191
+Check that gar is running and its IPC socket exists:
192
+```bash
193
+ls -la $XDG_RUNTIME_DIR/gar.sock
194
+```
195
+
196
+garbg will automatically reconnect with exponential backoff if gar restarts.
197
+
198
+### Wallpaper not appearing
199
+
200
+1. Verify the daemon is running:
201
+   ```bash
202
+   pgrep -f "garbg daemon"
203
+   ```
204
+
205
+2. Check daemon logs:
206
+   ```bash
207
+   garbg daemon  # Run in foreground to see logs
208
+   ```
209
+
210
+3. Verify RandR is working:
211
+   ```bash
212
+   garbg query monitors
213
+   ```
214
+
215
+### Animation stuttering
216
+
217
+- Reduce max FPS: `garbg set file.gif --animate --max-fps 30`
218
+- Ensure no heavy background processes
219
+
220
+## Example Complete Config
221
+
222
+`~/.config/gar/init.lua`:
223
+```lua
224
+-- Start garbg wallpaper daemon
225
+gar.exec_once("garbg daemon")
226
+
227
+-- Set initial wallpaper with slideshow
228
+gar.exec_once("sleep 0.5 && garbg set ~/Pictures/wallpapers --random --interval 10m")
229
+
230
+-- Wallpaper keybindings
231
+gar.bind("mod+bracketright", function() gar.exec("garbg next") end)
232
+gar.bind("mod+bracketleft", function() gar.exec("garbg prev") end)
233
+gar.bind("mod+shift+w", function() gar.exec("garbg random") end)
234
+gar.bind("mod+shift+p", function() gar.exec("garbg toggle") end)
235
+```
236
+
237
+`~/.config/garbg/config.toml`:
238
+```toml
239
+[general]
240
+mode = "fill"
241
+
242
+[animation]
243
+enabled = true
244
+max_fps = 60
245
+
246
+[cache]
247
+max_size_mb = 512
248
+
249
+[default]
250
+source = "~/Pictures/wallpapers"
251
+
252
+[default.slideshow]
253
+enabled = true
254
+interval = "10m"
255
+shuffle = true
256
+
257
+[[workspaces]]
258
+id = 1
259
+source = "~/Pictures/main.jpg"
260
+
261
+[[workspaces]]
262
+id = 9
263
+source = "~/Pictures/gaming.jpg"
264
+```
garbg/Cargo.tomlmodified
@@ -39,6 +39,7 @@ shellexpand.workspace = true
3939
 url.workspace = true
4040
 rand.workspace = true
4141
 indicatif.workspace = true
42
+sd-notify = "0.4"
4243
 
4344
 [features]
4445
 default = ["video"]
garbg/src/daemon/state.rsmodified
@@ -14,7 +14,7 @@ use crate::media::{scale_image, AnimatedGif, AnimatedPng, AnimatedWebP, Animatio
1414
 #[cfg(feature = "video")]
1515
 use crate::media::{VideoDecoder, is_video_file};
1616
 use crate::state::{detect_source_type, PlaylistState};
17
-use crate::x11::{AnimationRenderer, Connection};
17
+use crate::x11::{AnimationRenderer, Connection, Compositor, Monitor};
1818
 
1919
 use super::pid;
2020
 
@@ -172,15 +172,12 @@ impl Daemon {
172172
     /// Create a new daemon
173173
     ///
174174
     /// Establishes X11 connection and initializes state.
175
-    /// Fails early with helpful error messages if X11 is unavailable.
175
+    /// The daemon should be started by systemd after graphical-session.target
176
+    /// is active, so X11 should already be ready.
176177
     pub fn new(config: Config) -> Result<Self> {
178
+        // Connect to X11 (fail fast - systemd ensures session is ready)
177179
         let conn = Connection::new()
178
-            .context("Failed to initialize X11 connection for daemon")?;
179
-
180
-        // Verify connection is working
181
-        if !conn.is_alive() {
182
-            anyhow::bail!("X11 connection established but not responding");
183
-        }
180
+            .context("Failed to connect to X11. Is the graphical session active?")?;
184181
 
185182
         let (width, height) = conn.screen_dimensions();
186183
         tracing::info!("X11 connection established (screen: {}x{})", width, height);
@@ -232,6 +229,14 @@ impl Daemon {
232229
         let server = IpcServer::new().await?;
233230
         tracing::info!("Listening on {}", server.path().display());
234231
 
232
+        // Notify systemd that we're ready (for Type=notify services)
233
+        // This ensures gar-session.sh waits until the socket is actually listening
234
+        if let Err(e) = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) {
235
+            tracing::debug!("sd_notify failed (not running under systemd?): {}", e);
236
+        } else {
237
+            tracing::debug!("Notified systemd: READY=1");
238
+        }
239
+
235240
         // Set initial wallpaper from config if specified
236241
         if !self.state.config.default.source.is_empty() {
237242
             if let Err(e) = self.apply_default_wallpaper() {
@@ -242,6 +247,11 @@ impl Daemon {
242247
         // Try to connect to gar (optional)
243248
         let mut gar_client = self.try_connect_gar().await;
244249
 
250
+        // Reconnection state for gar
251
+        let mut gar_reconnect_backoff = Duration::from_secs(1);
252
+        let mut last_gar_reconnect = std::time::Instant::now();
253
+        let gar_max_backoff = Duration::from_secs(60);
254
+
245255
         // Track next slideshow time
246256
         let mut next_slideshow: Option<tokio::time::Instant> = self.state.slideshow_interval
247257
             .map(|d| tokio::time::Instant::now() + d);
@@ -259,8 +269,24 @@ impl Daemon {
259269
         let mut sigint = signal(SignalKind::interrupt())?;
260270
         let mut sighup = signal(SignalKind::hangup())?;
261271
 
272
+        // Track whether we need to attempt gar reconnection
273
+        let mut gar_needs_reconnect = gar_client.is_none();
274
+
262275
         // Main event loop
276
+        // Note: Session lifecycle is handled by systemd (PartOf=graphical-session.target)
263277
         loop {
278
+            // Compute reconnection delay (if needed) before select
279
+            let reconnect_delay = if gar_needs_reconnect {
280
+                let elapsed = last_gar_reconnect.elapsed();
281
+                if elapsed < gar_reconnect_backoff {
282
+                    Some(gar_reconnect_backoff - elapsed)
283
+                } else {
284
+                    Some(Duration::ZERO)
285
+                }
286
+            } else {
287
+                None
288
+            };
289
+
264290
             tokio::select! {
265291
                 // SIGTERM - graceful shutdown
266292
                 _ = sigterm.recv() => {
@@ -347,7 +373,7 @@ impl Daemon {
347373
                         .map(|d| tokio::time::Instant::now() + d);
348374
                 }
349375
 
350
-                // gar workspace events (only if connected)
376
+                // gar workspace/monitor events (only if connected)
351377
                 event = async {
352378
                     if let Some(ref mut client) = gar_client {
353379
                         client.read_event().await
@@ -357,6 +383,8 @@ impl Daemon {
357383
                 } => {
358384
                     match event {
359385
                         Ok(event) => {
386
+                            // Reset backoff on successful event
387
+                            gar_reconnect_backoff = Duration::from_secs(1);
360388
                             if let Err(e) = self.handle_gar_event(event) {
361389
                                 tracing::warn!("gar event handling failed: {}", e);
362390
                             }
@@ -364,6 +392,34 @@ impl Daemon {
364392
                         Err(e) => {
365393
                             tracing::debug!("gar connection lost: {}", e);
366394
                             gar_client = None;
395
+                            gar_needs_reconnect = true;
396
+                            last_gar_reconnect = std::time::Instant::now();
397
+                        }
398
+                    }
399
+                }
400
+
401
+                // gar reconnection timer (only when disconnected)
402
+                _ = async {
403
+                    if let Some(delay) = reconnect_delay {
404
+                        tokio::time::sleep(delay).await;
405
+                    } else {
406
+                        std::future::pending::<()>().await;
407
+                    }
408
+                } => {
409
+                    tracing::debug!("Attempting to reconnect to gar...");
410
+                    last_gar_reconnect = std::time::Instant::now();
411
+
412
+                    match self.try_connect_gar().await {
413
+                        Some(client) => {
414
+                            gar_client = Some(client);
415
+                            gar_needs_reconnect = false;
416
+                            gar_reconnect_backoff = Duration::from_secs(1);
417
+                            tracing::info!("Reconnected to gar");
418
+                        }
419
+                        None => {
420
+                            // Exponential backoff, max 60 seconds
421
+                            gar_reconnect_backoff = (gar_reconnect_backoff * 2).min(gar_max_backoff);
422
+                            tracing::debug!("gar reconnection failed, next attempt in {:?}", gar_reconnect_backoff);
367423
                         }
368424
                     }
369425
                 }
@@ -518,7 +574,7 @@ impl Daemon {
518574
             Command::Toggle => {
519575
                 self.state.paused = !self.state.paused;
520576
                 tracing::info!("Slideshow {}", if self.state.paused { "paused" } else { "resumed" });
521
-                Response::ok()
577
+                Response::ok_with_data(serde_json::json!({ "paused": self.state.paused }))
522578
             }
523579
             Command::Reload => {
524580
                 match self.reload_config() {
@@ -549,6 +605,21 @@ impl Daemon {
549605
                 // Subscriptions not yet implemented
550606
                 Response::error("Subscriptions not yet implemented")
551607
             }
608
+            Command::QueryMonitors => {
609
+                let monitors = self.get_monitors();
610
+                Response::ok_with_data(serde_json::json!({ "monitors": monitors }))
611
+            }
612
+            Command::QueryCurrent => {
613
+                let current = self.get_current_wallpaper_info();
614
+                Response::ok_with_data(current)
615
+            }
616
+            Command::SetMonitor { monitor, source, mode } => {
617
+                let scale_mode = mode.unwrap_or(self.state.config.general.mode);
618
+                match self.set_monitor_wallpaper(&monitor, &source, scale_mode) {
619
+                    Ok(_) => Response::ok(),
620
+                    Err(e) => Response::error(e.to_string()),
621
+                }
622
+            }
552623
         }
553624
     }
554625
 
@@ -560,8 +631,8 @@ impl Daemon {
560631
                 self.on_workspace_change(current)?;
561632
             }
562633
             GarEvent::Monitor { name, action } => {
563
-                tracing::debug!("Monitor {}: {}", action, name);
564
-                // TODO: Handle monitor changes
634
+                tracing::info!("Monitor event: {} {}", name, action);
635
+                self.on_monitor_change(&name, &action)?;
565636
             }
566637
             GarEvent::Focus { .. } => {
567638
                 // Ignore focus events
@@ -573,12 +644,70 @@ impl Daemon {
573644
         Ok(())
574645
     }
575646
 
647
+    /// Handle monitor hotplug event from gar
648
+    fn on_monitor_change(&mut self, name: &str, action: &str) -> Result<()> {
649
+        match action {
650
+            "added" => {
651
+                tracing::info!("Monitor added: {} - re-applying wallpapers", name);
652
+                // Re-apply wallpapers to all monitors when one is added
653
+                // This ensures the new monitor gets a wallpaper
654
+                self.refresh_wallpapers()?;
655
+            }
656
+            "removed" => {
657
+                tracing::info!("Monitor removed: {}", name);
658
+                // Remove monitor from our state if we're tracking per-monitor wallpapers
659
+                self.state.monitors.remove(name);
660
+            }
661
+            "changed" => {
662
+                tracing::info!("Monitor changed: {} - re-applying wallpapers", name);
663
+                // Resolution or position changed, re-apply wallpapers
664
+                self.refresh_wallpapers()?;
665
+            }
666
+            _ => {
667
+                tracing::debug!("Unknown monitor action: {} for {}", action, name);
668
+            }
669
+        }
670
+        Ok(())
671
+    }
672
+
673
+    /// Refresh wallpapers on all monitors
674
+    fn refresh_wallpapers(&mut self) -> Result<()> {
675
+        // If we have an active animation, restart it (picks up new screen size)
676
+        if self.animation.is_some() {
677
+            tracing::debug!("Active animation detected, will restart on next frame");
678
+            // Animation will pick up new screen dimensions on next render
679
+        }
680
+
681
+        // Check if we have per-monitor wallpapers
682
+        let monitors = Monitor::get_all(&self.conn).unwrap_or_default();
683
+
684
+        if monitors.len() > 1 && !self.state.monitors.is_empty() {
685
+            // Multiple monitors with per-monitor wallpapers: use compositor
686
+            self.composite_all_wallpapers(&monitors)?;
687
+            tracing::debug!("Wallpapers refreshed (composited {} monitors)", monitors.len());
688
+        } else if let Some(ref playlist) = self.state.playlist {
689
+            // Single monitor or no per-monitor state: use global wallpaper
690
+            if let Some(current) = playlist.current() {
691
+                let mode = playlist.mode;
692
+                let current = current.to_string();
693
+                self.set_wallpaper(&current, mode)?;
694
+                tracing::debug!("Wallpapers refreshed");
695
+            }
696
+        } else if !self.state.config.default.source.is_empty() {
697
+            // Fall back to default wallpaper
698
+            self.apply_default_wallpaper()?;
699
+        }
700
+
701
+        Ok(())
702
+    }
703
+
576704
     /// Try to connect to gar IPC
577705
     async fn try_connect_gar(&self) -> Option<GarIpcClient> {
578706
         match GarIpcClient::connect().await {
579707
             Ok(mut client) => {
580
-                if client.subscribe(&["workspace"]).await.is_ok() {
581
-                    tracing::info!("Connected to gar IPC");
708
+                // Subscribe to workspace and monitor events
709
+                if client.subscribe(&["workspace", "monitor"]).await.is_ok() {
710
+                    tracing::info!("Connected to gar IPC (subscribed to workspace, monitor events)");
582711
                     Some(client)
583712
                 } else {
584713
                     tracing::debug!("Failed to subscribe to gar events");
@@ -1088,6 +1217,199 @@ impl Daemon {
10881217
         images.sort();
10891218
         Ok(images)
10901219
     }
1220
+
1221
+    /// Get connected monitors info via RandR
1222
+    fn get_monitors(&self) -> Vec<serde_json::Value> {
1223
+        match Monitor::get_all(&self.conn) {
1224
+            Ok(monitors) if !monitors.is_empty() => {
1225
+                monitors.iter().map(|m| {
1226
+                    serde_json::json!({
1227
+                        "name": m.name,
1228
+                        "width": m.width,
1229
+                        "height": m.height,
1230
+                        "x": m.x,
1231
+                        "y": m.y,
1232
+                        "primary": m.primary,
1233
+                    })
1234
+                }).collect()
1235
+            }
1236
+            Ok(_) => {
1237
+                // No monitors detected, fall back to screen dimensions
1238
+                tracing::debug!("No monitors detected via RandR, using screen dimensions");
1239
+                let (width, height) = self.conn.screen_dimensions();
1240
+                vec![serde_json::json!({
1241
+                    "name": "default",
1242
+                    "width": width,
1243
+                    "height": height,
1244
+                    "x": 0,
1245
+                    "y": 0,
1246
+                    "primary": true,
1247
+                })]
1248
+            }
1249
+            Err(e) => {
1250
+                // RandR failed, fall back to screen dimensions
1251
+                tracing::warn!("RandR detection failed: {}, using screen dimensions", e);
1252
+                let (width, height) = self.conn.screen_dimensions();
1253
+                vec![serde_json::json!({
1254
+                    "name": "default",
1255
+                    "width": width,
1256
+                    "height": height,
1257
+                    "x": 0,
1258
+                    "y": 0,
1259
+                    "primary": true,
1260
+                })]
1261
+            }
1262
+        }
1263
+    }
1264
+
1265
+    /// Get current wallpaper info
1266
+    fn get_current_wallpaper_info(&self) -> serde_json::Value {
1267
+        let current_image = self.state.playlist.as_ref()
1268
+            .and_then(|p| p.current().map(|s| s.to_string()));
1269
+
1270
+        let mode = self.state.playlist.as_ref()
1271
+            .map(|p| format!("{}", p.mode))
1272
+            .unwrap_or_else(|| format!("{}", self.state.config.general.mode));
1273
+
1274
+        let animation_active = self.animation.is_some();
1275
+
1276
+        serde_json::json!({
1277
+            "source": current_image,
1278
+            "mode": mode,
1279
+            "paused": self.state.paused,
1280
+            "animation_active": animation_active,
1281
+            "workspace": self.state.current_workspace,
1282
+        })
1283
+    }
1284
+
1285
+    /// Set wallpaper for a specific monitor
1286
+    fn set_monitor_wallpaper(&mut self, monitor_name: &str, source: &str, mode: ScaleMode) -> Result<()> {
1287
+        // Get detected monitors
1288
+        let monitors = Monitor::get_all(&self.conn)?;
1289
+
1290
+        if monitors.is_empty() {
1291
+            // Fall back to setting global wallpaper if no monitors detected
1292
+            tracing::warn!("No monitors detected, setting wallpaper globally");
1293
+            return self.set_wallpaper(source, mode);
1294
+        }
1295
+
1296
+        // Find the target monitor
1297
+        let target_monitor = monitors.iter()
1298
+            .find(|m| m.name == monitor_name)
1299
+            .ok_or_else(|| anyhow::anyhow!(
1300
+                "Monitor '{}' not found. Available: {:?}",
1301
+                monitor_name,
1302
+                monitors.iter().map(|m| &m.name).collect::<Vec<_>>()
1303
+            ))?;
1304
+
1305
+        // Load and scale the image for this monitor
1306
+        let expanded = shellexpand::tilde(source);
1307
+        let image = ImageLoader::load_file(expanded.as_ref())?;
1308
+
1309
+        // Store wallpaper state for this monitor
1310
+        self.state.monitors.insert(monitor_name.to_string(), MonitorWallpaper {
1311
+            name: monitor_name.to_string(),
1312
+            source: source.to_string(),
1313
+            mode,
1314
+        });
1315
+
1316
+        tracing::info!(
1317
+            "Set wallpaper for monitor {}: {} (mode: {})",
1318
+            monitor_name,
1319
+            source,
1320
+            mode
1321
+        );
1322
+
1323
+        // If only one monitor, set wallpaper directly
1324
+        if monitors.len() == 1 {
1325
+            let scaled = scale_image(
1326
+                &image,
1327
+                target_monitor.width as u32,
1328
+                target_monitor.height as u32,
1329
+                mode,
1330
+            );
1331
+            return self.conn.set_wallpaper(&scaled);
1332
+        }
1333
+
1334
+        // Multiple monitors: composite all wallpapers
1335
+        self.composite_all_wallpapers(&monitors)
1336
+    }
1337
+
1338
+    /// Composite wallpapers for all monitors and set the result
1339
+    fn composite_all_wallpapers(&mut self, monitors: &[Monitor]) -> Result<()> {
1340
+        if monitors.is_empty() {
1341
+            return Ok(());
1342
+        }
1343
+
1344
+        let compositor = Compositor::new(monitors);
1345
+        let mut wallpapers = Vec::new();
1346
+
1347
+        // Get global default wallpaper for monitors without specific wallpaper
1348
+        let default_image = if !self.state.config.default.source.is_empty() {
1349
+            let expanded = shellexpand::tilde(&self.state.config.default.source);
1350
+            ImageLoader::load_file(expanded.as_ref()).ok()
1351
+        } else if let Some(ref playlist) = self.state.playlist {
1352
+            playlist.current()
1353
+                .and_then(|path| {
1354
+                    let expanded = shellexpand::tilde(path);
1355
+                    ImageLoader::load_file(expanded.as_ref()).ok()
1356
+                })
1357
+        } else {
1358
+            None
1359
+        };
1360
+
1361
+        for monitor in monitors {
1362
+            let image = if let Some(wp_state) = self.state.monitors.get(&monitor.name) {
1363
+                // Use the per-monitor wallpaper
1364
+                let expanded = shellexpand::tilde(&wp_state.source);
1365
+                match ImageLoader::load_file(expanded.as_ref()) {
1366
+                    Ok(img) => img,
1367
+                    Err(e) => {
1368
+                        tracing::warn!("Failed to load wallpaper for {}: {}", monitor.name, e);
1369
+                        if let Some(ref def) = default_image {
1370
+                            def.clone()
1371
+                        } else {
1372
+                            // Create a black image as fallback
1373
+                            image::RgbaImage::from_pixel(
1374
+                                monitor.width as u32,
1375
+                                monitor.height as u32,
1376
+                                image::Rgba([0, 0, 0, 255])
1377
+                            )
1378
+                        }
1379
+                    }
1380
+                }
1381
+            } else if let Some(ref def) = default_image {
1382
+                // Use the default wallpaper
1383
+                def.clone()
1384
+            } else {
1385
+                // No wallpaper, use black
1386
+                image::RgbaImage::from_pixel(
1387
+                    monitor.width as u32,
1388
+                    monitor.height as u32,
1389
+                    image::Rgba([0, 0, 0, 255])
1390
+                )
1391
+            };
1392
+
1393
+            let mode = self.state.monitors.get(&monitor.name)
1394
+                .map(|wp| wp.mode)
1395
+                .unwrap_or(self.state.config.general.mode);
1396
+
1397
+            wallpapers.push(Compositor::create_monitor_wallpaper(monitor, &image, mode));
1398
+        }
1399
+
1400
+        // Composite and set
1401
+        let composited = compositor.composite(&wallpapers);
1402
+        self.conn.set_wallpaper(&composited)?;
1403
+
1404
+        tracing::debug!(
1405
+            "Composited {} monitors ({}x{})",
1406
+            monitors.len(),
1407
+            compositor.total_width,
1408
+            compositor.total_height
1409
+        );
1410
+
1411
+        Ok(())
1412
+    }
10911413
 }
10921414
 
10931415
 /// RAII guard for PID file cleanup
garbg/src/ipc/gar_client.rsmodified
@@ -3,6 +3,7 @@
33
 use anyhow::{Context, Result};
44
 use serde::Deserialize;
55
 use std::path::PathBuf;
6
+use std::time::Duration;
67
 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
78
 use tokio::net::UnixStream;
89
 
@@ -27,6 +28,29 @@ impl GarIpcClient {
2728
         Ok(Self { reader, writer: write_half })
2829
     }
2930
 
31
+    /// Connect with retry logic and exponential backoff
32
+    pub async fn connect_with_retry(max_attempts: u32, initial_delay: Duration) -> Result<Self> {
33
+        let mut delay = initial_delay;
34
+        let max_delay = Duration::from_secs(60);
35
+
36
+        for attempt in 1..=max_attempts {
37
+            match Self::connect().await {
38
+                Ok(client) => return Ok(client),
39
+                Err(e) if attempt < max_attempts => {
40
+                    tracing::debug!(
41
+                        "gar connection attempt {}/{} failed: {}, retrying in {:?}...",
42
+                        attempt, max_attempts, e, delay
43
+                    );
44
+                    tokio::time::sleep(delay).await;
45
+                    // Exponential backoff with max cap
46
+                    delay = (delay * 2).min(max_delay);
47
+                }
48
+                Err(e) => return Err(e),
49
+            }
50
+        }
51
+        unreachable!()
52
+    }
53
+
3054
     /// Get gar's socket path
3155
     fn socket_path() -> Result<PathBuf> {
3256
         let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
@@ -35,6 +59,51 @@ impl GarIpcClient {
3559
         Ok(PathBuf::from(runtime_dir).join("gar.sock"))
3660
     }
3761
 
62
+    /// Send a command to gar and get response
63
+    pub async fn send_command(&mut self, command: &str, args: serde_json::Value) -> Result<serde_json::Value> {
64
+        let cmd = serde_json::json!({
65
+            "command": command,
66
+            "args": args
67
+        });
68
+
69
+        let json = serde_json::to_string(&cmd)?;
70
+        self.writer.write_all(json.as_bytes()).await?;
71
+        self.writer.write_all(b"\n").await?;
72
+
73
+        let mut line = String::new();
74
+        self.reader.read_line(&mut line).await?;
75
+
76
+        let response: serde_json::Value = serde_json::from_str(&line)
77
+            .context("Failed to parse gar response")?;
78
+
79
+        Ok(response)
80
+    }
81
+
82
+    /// Query gar for monitor/output information
83
+    pub async fn query_monitors(&mut self) -> Result<Vec<GarMonitor>> {
84
+        let response = self.send_command("get_monitors", serde_json::json!({})).await?;
85
+
86
+        if let Some(data) = response.get("data") {
87
+            if let Some(monitors) = data.as_array() {
88
+                return Ok(monitors.iter()
89
+                    .filter_map(|m| serde_json::from_value(m.clone()).ok())
90
+                    .collect());
91
+            }
92
+        }
93
+
94
+        // Fallback: try "get_outputs" command (i3-compat)
95
+        let response = self.send_command("get_outputs", serde_json::json!({})).await?;
96
+        if let Some(data) = response.get("data") {
97
+            if let Some(monitors) = data.as_array() {
98
+                return Ok(monitors.iter()
99
+                    .filter_map(|m| serde_json::from_value(m.clone()).ok())
100
+                    .collect());
101
+            }
102
+        }
103
+
104
+        Ok(vec![])
105
+    }
106
+
38107
     /// Subscribe to gar events
39108
     pub async fn subscribe(&mut self, events: &[&str]) -> Result<()> {
40109
         let cmd = serde_json::json!({
@@ -153,3 +222,32 @@ impl From<RawGarEvent> for GarEvent {
153222
         }
154223
     }
155224
 }
225
+
226
+/// Monitor info from gar window manager
227
+#[derive(Debug, Clone, Deserialize)]
228
+pub struct GarMonitor {
229
+    /// Monitor name (e.g., "DP-1", "HDMI-1")
230
+    pub name: String,
231
+    /// X position
232
+    #[serde(default)]
233
+    pub x: i32,
234
+    /// Y position
235
+    #[serde(default)]
236
+    pub y: i32,
237
+    /// Width in pixels
238
+    #[serde(default)]
239
+    pub width: u32,
240
+    /// Height in pixels
241
+    #[serde(default)]
242
+    pub height: u32,
243
+    /// Whether this is the primary monitor
244
+    #[serde(default)]
245
+    pub primary: bool,
246
+    /// Whether the monitor is active/connected
247
+    #[serde(default = "default_active")]
248
+    pub active: bool,
249
+}
250
+
251
+fn default_active() -> bool {
252
+    true
253
+}
garbg/src/ipc/protocol.rsmodified
@@ -86,6 +86,22 @@ pub enum Command {
8686
 
8787
     /// Unsubscribe from events
8888
     Unsubscribe { events: Vec<String> },
89
+
90
+    /// Query connected monitors
91
+    QueryMonitors,
92
+
93
+    /// Query current wallpaper info
94
+    QueryCurrent,
95
+
96
+    /// Set wallpaper for a specific monitor
97
+    SetMonitor {
98
+        /// Monitor name (e.g., "DP-1", "HDMI-1")
99
+        monitor: String,
100
+        /// Wallpaper source
101
+        source: String,
102
+        #[serde(default)]
103
+        mode: Option<ScaleMode>,
104
+    },
89105
 }
90106
 
91107
 /// Response to a command
garbg/src/main.rsmodified
@@ -83,6 +83,42 @@ enum Commands {
8383
 
8484
     /// Get current status
8585
     Status,
86
+
87
+    /// Pause animations and slideshow
88
+    Pause,
89
+
90
+    /// Resume animations and slideshow
91
+    Resume,
92
+
93
+    /// Toggle pause state
94
+    Toggle,
95
+
96
+    /// Query daemon information
97
+    Query {
98
+        #[command(subcommand)]
99
+        what: QueryCommand,
100
+    },
101
+
102
+    /// Set wallpaper for a specific monitor
103
+    SetMonitor {
104
+        /// Monitor name (e.g., "DP-1", "HDMI-1")
105
+        monitor: String,
106
+
107
+        /// Wallpaper source
108
+        source: String,
109
+
110
+        /// Scaling mode (fill, fit, stretch, center, tile)
111
+        #[arg(short, long, default_value = "fill")]
112
+        mode: String,
113
+    },
114
+}
115
+
116
+#[derive(Subcommand)]
117
+enum QueryCommand {
118
+    /// List connected monitors (JSON)
119
+    Monitors,
120
+    /// Current wallpaper info (JSON)
121
+    Current,
86122
 }
87123
 
88124
 /// Parse a duration string like "5m", "30s", "1h"
@@ -133,6 +169,24 @@ fn main() -> Result<()> {
133169
         Commands::Status => {
134170
             print_status()?;
135171
         }
172
+        Commands::Pause => {
173
+            cmd_pause()?;
174
+        }
175
+        Commands::Resume => {
176
+            cmd_resume()?;
177
+        }
178
+        Commands::Toggle => {
179
+            cmd_toggle()?;
180
+        }
181
+        Commands::Query { what } => {
182
+            match what {
183
+                QueryCommand::Monitors => cmd_query_monitors()?,
184
+                QueryCommand::Current => cmd_query_current()?,
185
+            }
186
+        }
187
+        Commands::SetMonitor { monitor, source, mode } => {
188
+            cmd_set_monitor(&monitor, &source, &mode)?;
189
+        }
136190
     }
137191
 
138192
     Ok(())
@@ -168,39 +222,43 @@ fn set_wallpaper(
168222
     max_fps: u32,
169223
 ) -> Result<()> {
170224
     use garbg::config::ScaleMode;
171
-    use garbg::ipc::is_daemon_running;
225
+    use garbg::ipc::send_command_blocking;
172226
 
173227
     let scale_mode: ScaleMode = mode.parse()?;
174228
 
175229
     // Normalize GitHub URLs first
176230
     let normalized_source = normalize_github_url(source);
177231
 
178
-    // If daemon is running, delegate to it
179
-    if is_daemon_running() {
180
-        let interval_secs = interval.map(|d| d.as_secs());
181
-
182
-        let cmd = Command::Set {
183
-            source: normalized_source.clone(),
184
-            mode: Some(scale_mode),
185
-            monitor: None,
186
-            interval_secs,
187
-            shuffle: random,
188
-            animate,
189
-            max_fps,
190
-        };
191
-
192
-        let response = send_with_spinner(&cmd, "Loading wallpaper...")?;
232
+    // Try to delegate to daemon
233
+    let interval_secs = interval.map(|d| d.as_secs());
234
+
235
+    let cmd = Command::Set {
236
+        source: normalized_source.clone(),
237
+        mode: Some(scale_mode),
238
+        monitor: None,
239
+        interval_secs,
240
+        shuffle: random,
241
+        animate,
242
+        max_fps,
243
+    };
193244
 
194
-        if response.success {
195
-            if let Some(secs) = interval_secs {
196
-                println!("Slideshow scheduled: {} (every {}s, shuffle: {})",
197
-                    normalized_source, secs, random);
198
-            } else {
199
-                println!("Wallpaper set via daemon: {}", normalized_source);
245
+    // Try to send to daemon (should be ready if systemd started it correctly)
246
+    match send_command_blocking(&cmd) {
247
+        Ok(response) => {
248
+            if response.success {
249
+                if let Some(secs) = interval_secs {
250
+                    println!("Slideshow scheduled: {} (every {}s, shuffle: {})",
251
+                        normalized_source, secs, random);
252
+                } else {
253
+                    println!("Wallpaper set via daemon: {}", normalized_source);
254
+                }
255
+                return Ok(());
256
+            } else if let Some(err) = response.error {
257
+                anyhow::bail!("Daemon error: {}", err);
200258
             }
201
-            return Ok(());
202
-        } else if let Some(err) = response.error {
203
-            anyhow::bail!("Daemon error: {}", err);
259
+        }
260
+        Err(_) => {
261
+            // Daemon not available - fall through to standalone
204262
         }
205263
     }
206264
 
@@ -812,3 +870,116 @@ fn print_status() -> Result<()> {
812870
     }
813871
     Ok(())
814872
 }
873
+
874
+fn cmd_pause() -> Result<()> {
875
+    use garbg::ipc::{is_daemon_running, send_command_blocking};
876
+
877
+    if !is_daemon_running() {
878
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
879
+    }
880
+
881
+    let response = send_command_blocking(&Command::Pause)?;
882
+    if response.success {
883
+        println!("Paused");
884
+    } else if let Some(err) = response.error {
885
+        anyhow::bail!("Failed to pause: {}", err);
886
+    }
887
+    Ok(())
888
+}
889
+
890
+fn cmd_resume() -> Result<()> {
891
+    use garbg::ipc::{is_daemon_running, send_command_blocking};
892
+
893
+    if !is_daemon_running() {
894
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
895
+    }
896
+
897
+    let response = send_command_blocking(&Command::Resume)?;
898
+    if response.success {
899
+        println!("Resumed");
900
+    } else if let Some(err) = response.error {
901
+        anyhow::bail!("Failed to resume: {}", err);
902
+    }
903
+    Ok(())
904
+}
905
+
906
+fn cmd_toggle() -> Result<()> {
907
+    use garbg::ipc::{is_daemon_running, send_command_blocking};
908
+
909
+    if !is_daemon_running() {
910
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
911
+    }
912
+
913
+    let response = send_command_blocking(&Command::Toggle)?;
914
+    if response.success {
915
+        if let Some(data) = response.data {
916
+            if let Some(paused) = data.get("paused").and_then(|v| v.as_bool()) {
917
+                println!("{}", if paused { "Paused" } else { "Resumed" });
918
+            }
919
+        }
920
+    } else if let Some(err) = response.error {
921
+        anyhow::bail!("Failed to toggle: {}", err);
922
+    }
923
+    Ok(())
924
+}
925
+
926
+fn cmd_query_monitors() -> Result<()> {
927
+    use garbg::ipc::{is_daemon_running, send_command_blocking};
928
+
929
+    if !is_daemon_running() {
930
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
931
+    }
932
+
933
+    let response = send_command_blocking(&Command::QueryMonitors)?;
934
+    if response.success {
935
+        if let Some(data) = response.data {
936
+            println!("{}", serde_json::to_string_pretty(&data)?);
937
+        }
938
+    } else if let Some(err) = response.error {
939
+        anyhow::bail!("Failed to query monitors: {}", err);
940
+    }
941
+    Ok(())
942
+}
943
+
944
+fn cmd_query_current() -> Result<()> {
945
+    use garbg::ipc::{is_daemon_running, send_command_blocking};
946
+
947
+    if !is_daemon_running() {
948
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
949
+    }
950
+
951
+    let response = send_command_blocking(&Command::QueryCurrent)?;
952
+    if response.success {
953
+        if let Some(data) = response.data {
954
+            println!("{}", serde_json::to_string_pretty(&data)?);
955
+        }
956
+    } else if let Some(err) = response.error {
957
+        anyhow::bail!("Failed to query current: {}", err);
958
+    }
959
+    Ok(())
960
+}
961
+
962
+fn cmd_set_monitor(monitor: &str, source: &str, mode: &str) -> Result<()> {
963
+    use garbg::config::ScaleMode;
964
+    use garbg::ipc::is_daemon_running;
965
+
966
+    if !is_daemon_running() {
967
+        anyhow::bail!("Daemon not running. Start with: garbg daemon");
968
+    }
969
+
970
+    let scale_mode: ScaleMode = mode.parse()?;
971
+
972
+    let cmd = Command::SetMonitor {
973
+        monitor: monitor.to_string(),
974
+        source: source.to_string(),
975
+        mode: Some(scale_mode),
976
+    };
977
+
978
+    let response = send_with_spinner(&cmd, &format!("Setting wallpaper on {}...", monitor))?;
979
+    if response.success {
980
+        println!("Wallpaper set on {}: {}", monitor, source);
981
+    } else if let Some(err) = response.error {
982
+        anyhow::bail!("Failed to set monitor wallpaper: {}", err);
983
+    }
984
+    Ok(())
985
+}
garbg/src/x11/compositor.rsadded
@@ -0,0 +1,177 @@
1
+//! Multi-monitor wallpaper compositor
2
+//!
3
+//! Composites individual monitor wallpapers onto a single root window pixmap.
4
+
5
+use image::RgbaImage;
6
+
7
+use super::monitors::Monitor;
8
+use crate::config::ScaleMode;
9
+use crate::media::scale_image;
10
+
11
+/// Represents a wallpaper for a specific monitor region
12
+#[derive(Debug, Clone)]
13
+pub struct MonitorWallpaper {
14
+    /// Monitor this wallpaper is for
15
+    pub monitor: Monitor,
16
+    /// Scaled wallpaper image (sized to monitor dimensions)
17
+    pub image: RgbaImage,
18
+}
19
+
20
+/// Compositor for combining multiple monitor wallpapers
21
+pub struct Compositor {
22
+    /// Total width of the root window
23
+    pub total_width: u32,
24
+    /// Total height of the root window
25
+    pub total_height: u32,
26
+}
27
+
28
+impl Compositor {
29
+    /// Create a new compositor for the given monitors
30
+    pub fn new(monitors: &[Monitor]) -> Self {
31
+        // Calculate bounding box that contains all monitors
32
+        let (total_width, total_height) = Self::calculate_bounds(monitors);
33
+        Self {
34
+            total_width,
35
+            total_height,
36
+        }
37
+    }
38
+
39
+    /// Calculate the bounding box that contains all monitors
40
+    fn calculate_bounds(monitors: &[Monitor]) -> (u32, u32) {
41
+        if monitors.is_empty() {
42
+            return (0, 0);
43
+        }
44
+
45
+        let mut max_x: i32 = 0;
46
+        let mut max_y: i32 = 0;
47
+
48
+        for m in monitors {
49
+            let right = m.x as i32 + m.width as i32;
50
+            let bottom = m.y as i32 + m.height as i32;
51
+            max_x = max_x.max(right);
52
+            max_y = max_y.max(bottom);
53
+        }
54
+
55
+        (max_x as u32, max_y as u32)
56
+    }
57
+
58
+    /// Composite multiple monitor wallpapers into a single image
59
+    ///
60
+    /// Returns a single RgbaImage that covers the entire root window,
61
+    /// with each monitor's wallpaper placed at the correct position.
62
+    pub fn composite(&self, wallpapers: &[MonitorWallpaper]) -> RgbaImage {
63
+        // Create a blank canvas
64
+        let mut canvas = RgbaImage::new(self.total_width, self.total_height);
65
+
66
+        // Fill with black background (for any uncovered areas)
67
+        for pixel in canvas.pixels_mut() {
68
+            *pixel = image::Rgba([0, 0, 0, 255]);
69
+        }
70
+
71
+        // Composite each wallpaper at its monitor position
72
+        for wp in wallpapers {
73
+            let monitor = &wp.monitor;
74
+            let image = &wp.image;
75
+
76
+            // Copy pixels from wallpaper to canvas at monitor position
77
+            for (x, y, pixel) in image.enumerate_pixels() {
78
+                let canvas_x = monitor.x as u32 + x;
79
+                let canvas_y = monitor.y as u32 + y;
80
+
81
+                if canvas_x < self.total_width && canvas_y < self.total_height {
82
+                    canvas.put_pixel(canvas_x, canvas_y, *pixel);
83
+                }
84
+            }
85
+        }
86
+
87
+        canvas
88
+    }
89
+
90
+    /// Create a wallpaper for a single monitor by scaling the source image
91
+    pub fn create_monitor_wallpaper(
92
+        monitor: &Monitor,
93
+        source_image: &RgbaImage,
94
+        mode: ScaleMode,
95
+    ) -> MonitorWallpaper {
96
+        let scaled = scale_image(
97
+            source_image,
98
+            monitor.width as u32,
99
+            monitor.height as u32,
100
+            mode,
101
+        );
102
+
103
+        MonitorWallpaper {
104
+            monitor: monitor.clone(),
105
+            image: scaled,
106
+        }
107
+    }
108
+
109
+    /// Create wallpapers for all monitors using the same source image
110
+    pub fn create_wallpapers_uniform(
111
+        monitors: &[Monitor],
112
+        source_image: &RgbaImage,
113
+        mode: ScaleMode,
114
+    ) -> Vec<MonitorWallpaper> {
115
+        monitors
116
+            .iter()
117
+            .map(|m| Self::create_monitor_wallpaper(m, source_image, mode))
118
+            .collect()
119
+    }
120
+}
121
+
122
+#[cfg(test)]
123
+mod tests {
124
+    use super::*;
125
+
126
+    fn make_monitor(name: &str, x: i16, y: i16, width: u16, height: u16) -> Monitor {
127
+        Monitor {
128
+            name: name.to_string(),
129
+            x,
130
+            y,
131
+            width,
132
+            height,
133
+            primary: false,
134
+        }
135
+    }
136
+
137
+    #[test]
138
+    fn test_calculate_bounds_single() {
139
+        let monitors = vec![make_monitor("DP-1", 0, 0, 1920, 1080)];
140
+        let (w, h) = Compositor::calculate_bounds(&monitors);
141
+        assert_eq!(w, 1920);
142
+        assert_eq!(h, 1080);
143
+    }
144
+
145
+    #[test]
146
+    fn test_calculate_bounds_dual_horizontal() {
147
+        let monitors = vec![
148
+            make_monitor("DP-1", 0, 0, 1920, 1080),
149
+            make_monitor("DP-2", 1920, 0, 1920, 1080),
150
+        ];
151
+        let (w, h) = Compositor::calculate_bounds(&monitors);
152
+        assert_eq!(w, 3840);
153
+        assert_eq!(h, 1080);
154
+    }
155
+
156
+    #[test]
157
+    fn test_calculate_bounds_dual_vertical() {
158
+        let monitors = vec![
159
+            make_monitor("DP-1", 0, 0, 1920, 1080),
160
+            make_monitor("DP-2", 0, 1080, 1920, 1080),
161
+        ];
162
+        let (w, h) = Compositor::calculate_bounds(&monitors);
163
+        assert_eq!(w, 1920);
164
+        assert_eq!(h, 2160);
165
+    }
166
+
167
+    #[test]
168
+    fn test_calculate_bounds_mixed() {
169
+        let monitors = vec![
170
+            make_monitor("DP-1", 0, 0, 2560, 1440),
171
+            make_monitor("DP-2", 2560, 200, 1920, 1080),
172
+        ];
173
+        let (w, h) = Compositor::calculate_bounds(&monitors);
174
+        assert_eq!(w, 4480);
175
+        assert_eq!(h, 1440); // max(1440, 200+1080) = max(1440, 1280) = 1440
176
+    }
177
+}
garbg/src/x11/mod.rsmodified
@@ -7,8 +7,10 @@ mod connection;
77
 mod renderer;
88
 mod monitors;
99
 mod animation;
10
+mod compositor;
1011
 
1112
 pub use connection::{Connection, X11Error};
1213
 pub use renderer::Renderer;
1314
 pub use monitors::Monitor;
1415
 pub use animation::{AnimationRenderer, DoubleBuffer};
16
+pub use compositor::{Compositor, MonitorWallpaper};
garbg/src/x11/monitors.rsmodified
@@ -1,6 +1,9 @@
11
 //! Multi-monitor detection via RandR
22
 
3
-use anyhow::Result;
3
+use anyhow::{Context, Result};
4
+use x11rb::connection::Connection as X11Connection;
5
+use x11rb::protocol::randr::{self, ConnectionExt as RandrExt};
6
+use x11rb::protocol::xproto::Timestamp;
47
 
58
 /// Represents a connected monitor/output
69
 #[derive(Debug, Clone)]
@@ -20,10 +23,98 @@ pub struct Monitor {
2023
 }
2124
 
2225
 impl Monitor {
23
-    /// Get all connected monitors
24
-    pub fn get_all(_conn: &super::Connection) -> Result<Vec<Monitor>> {
25
-        // TODO: Implement RandR monitor detection
26
-        // For now, return a single monitor covering the whole screen
27
-        Ok(vec![])
26
+    /// Get all connected monitors via RandR
27
+    pub fn get_all(conn: &super::Connection) -> Result<Vec<Monitor>> {
28
+        let root = conn.root();
29
+        let xconn = conn.conn();
30
+
31
+        // Query RandR version to ensure it's available
32
+        let version = xconn.randr_query_version(1, 5)?
33
+            .reply()
34
+            .context("RandR extension not available")?;
35
+
36
+        tracing::debug!("RandR version: {}.{}", version.major_version, version.minor_version);
37
+
38
+        // Get screen resources
39
+        let resources = xconn.randr_get_screen_resources_current(root)?
40
+            .reply()
41
+            .context("Failed to get screen resources")?;
42
+
43
+        // Get primary output (if any)
44
+        let primary = xconn.randr_get_output_primary(root)?
45
+            .reply()
46
+            .context("Failed to get primary output")?
47
+            .output;
48
+
49
+        let mut monitors = Vec::new();
50
+
51
+        // Process each output
52
+        for &output in &resources.outputs {
53
+            match Self::get_output_info(xconn, output, resources.config_timestamp, primary) {
54
+                Ok(Some(monitor)) => {
55
+                    tracing::debug!(
56
+                        "Found monitor: {} ({}x{} at {},{}{})",
57
+                        monitor.name,
58
+                        monitor.width,
59
+                        monitor.height,
60
+                        monitor.x,
61
+                        monitor.y,
62
+                        if monitor.primary { ", primary" } else { "" }
63
+                    );
64
+                    monitors.push(monitor);
65
+                }
66
+                Ok(None) => {
67
+                    // Output not connected or not active
68
+                }
69
+                Err(e) => {
70
+                    tracing::warn!("Failed to get output info: {}", e);
71
+                }
72
+            }
73
+        }
74
+
75
+        // Sort monitors by x position (left to right)
76
+        monitors.sort_by_key(|m| m.x);
77
+
78
+        Ok(monitors)
79
+    }
80
+
81
+    /// Get info for a single output
82
+    fn get_output_info<C: X11Connection>(
83
+        conn: &C,
84
+        output: randr::Output,
85
+        timestamp: Timestamp,
86
+        primary_output: randr::Output,
87
+    ) -> Result<Option<Monitor>> {
88
+        let output_info = conn.randr_get_output_info(output, timestamp)?
89
+            .reply()
90
+            .context("Failed to get output info")?;
91
+
92
+        // Skip disconnected outputs
93
+        if output_info.connection != randr::Connection::CONNECTED {
94
+            return Ok(None);
95
+        }
96
+
97
+        // Skip outputs without a CRTC (not active)
98
+        let crtc = output_info.crtc;
99
+        if crtc == 0 {
100
+            return Ok(None);
101
+        }
102
+
103
+        // Get CRTC info for position and dimensions
104
+        let crtc_info = conn.randr_get_crtc_info(crtc, timestamp)?
105
+            .reply()
106
+            .context("Failed to get CRTC info")?;
107
+
108
+        // Get output name
109
+        let name = String::from_utf8_lossy(&output_info.name).to_string();
110
+
111
+        Ok(Some(Monitor {
112
+            name,
113
+            x: crtc_info.x,
114
+            y: crtc_info.y,
115
+            width: crtc_info.width,
116
+            height: crtc_info.height,
117
+            primary: output == primary_output,
118
+        }))
28119
     }
29120
 }