gardesk/garbg / 36a9617

Browse files

Add animated GIF support with daemon integration

- GIF decoder with frame-by-frame access and timing
- Double-buffered X11 rendering for smooth animation
- Animation event loop integrated into daemon
- CLI --animate flag and --max-fps option
- X11 error handling improvements
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
36a9617b3491c59e4d0ffbdfe592ff5497bb54eb
Parents
92945ac
Tree
19cd850

10 changed files

StatusFile+-
A garbg/src/daemon/animation_loop.rs 250 0
M garbg/src/daemon/mod.rs 2 0
M garbg/src/daemon/state.rs 205 10
M garbg/src/ipc/protocol.rs 11 0
M garbg/src/main.rs 107 10
A garbg/src/media/gif.rs 192 0
M garbg/src/media/mod.rs 2 0
A garbg/src/x11/animation.rs 201 0
M garbg/src/x11/connection.rs 49 2
M garbg/src/x11/mod.rs 3 1
garbg/src/daemon/animation_loop.rsadded
@@ -0,0 +1,250 @@
1
+//! Animation playback loop for GIF wallpapers
2
+//!
3
+//! Manages frame timing and rendering for animated wallpapers.
4
+
5
+use anyhow::Result;
6
+use std::sync::atomic::{AtomicBool, Ordering};
7
+use std::sync::Arc;
8
+use std::time::{Duration, Instant};
9
+
10
+use crate::config::ScaleMode;
11
+use crate::media::{scale_image, AnimatedGif};
12
+use crate::x11::{AnimationRenderer, Connection};
13
+
14
+/// Configuration for the animation loop
15
+#[derive(Debug, Clone)]
16
+pub struct AnimationConfig {
17
+    /// Maximum FPS (0 = unlimited, capped at source FPS)
18
+    pub max_fps: u32,
19
+    /// Whether to skip frames when running behind
20
+    pub adaptive_skip: bool,
21
+    /// Scaling mode for frames
22
+    pub scale_mode: ScaleMode,
23
+}
24
+
25
+impl Default for AnimationConfig {
26
+    fn default() -> Self {
27
+        Self {
28
+            max_fps: 60,
29
+            adaptive_skip: true,
30
+            scale_mode: ScaleMode::Fill,
31
+        }
32
+    }
33
+}
34
+
35
+/// Animation loop state
36
+pub struct AnimationLoop {
37
+    /// The animated GIF being played
38
+    gif: AnimatedGif,
39
+    /// X11 animation renderer
40
+    renderer: AnimationRenderer,
41
+    /// Pre-scaled frames (cached)
42
+    scaled_frames: Vec<image::RgbaImage>,
43
+    /// Configuration
44
+    config: AnimationConfig,
45
+    /// Whether the animation is paused
46
+    paused: Arc<AtomicBool>,
47
+    /// Whether to stop the animation
48
+    stop: Arc<AtomicBool>,
49
+}
50
+
51
+impl AnimationLoop {
52
+    /// Create a new animation loop for a GIF
53
+    pub fn new(
54
+        gif: AnimatedGif,
55
+        conn: &Connection,
56
+        config: AnimationConfig,
57
+    ) -> Result<Self> {
58
+        let renderer = AnimationRenderer::new(conn)?;
59
+        let (screen_width, screen_height) = renderer.dimensions();
60
+
61
+        // Pre-scale all frames
62
+        let scaled_frames: Vec<image::RgbaImage> = gif
63
+            .frames()
64
+            .iter()
65
+            .map(|frame| {
66
+                scale_image(
67
+                    &frame.image,
68
+                    screen_width as u32,
69
+                    screen_height as u32,
70
+                    config.scale_mode,
71
+                )
72
+            })
73
+            .collect();
74
+
75
+        Ok(Self {
76
+            gif,
77
+            renderer,
78
+            scaled_frames,
79
+            config,
80
+            paused: Arc::new(AtomicBool::new(false)),
81
+            stop: Arc::new(AtomicBool::new(false)),
82
+        })
83
+    }
84
+
85
+    /// Load a GIF from a file and create an animation loop
86
+    pub fn from_file(
87
+        path: &str,
88
+        conn: &Connection,
89
+        config: AnimationConfig,
90
+    ) -> Result<Self> {
91
+        let gif = AnimatedGif::load(path)?;
92
+        Self::new(gif, conn, config)
93
+    }
94
+
95
+    /// Get a handle to pause/resume the animation
96
+    pub fn pause_handle(&self) -> Arc<AtomicBool> {
97
+        Arc::clone(&self.paused)
98
+    }
99
+
100
+    /// Get a handle to stop the animation
101
+    pub fn stop_handle(&self) -> Arc<AtomicBool> {
102
+        Arc::clone(&self.stop)
103
+    }
104
+
105
+    /// Run the animation loop (blocking)
106
+    ///
107
+    /// This will loop forever until `stop` is set to true.
108
+    pub fn run(&mut self, conn: &mut Connection) -> Result<()> {
109
+        if self.scaled_frames.is_empty() {
110
+            anyhow::bail!("No frames to display");
111
+        }
112
+
113
+        // Calculate minimum frame duration based on max_fps
114
+        let min_frame_duration = if self.config.max_fps > 0 {
115
+            Duration::from_secs_f64(1.0 / self.config.max_fps as f64)
116
+        } else {
117
+            Duration::ZERO
118
+        };
119
+
120
+        let mut frame_index = 0;
121
+        let mut next_frame_time = Instant::now();
122
+        let mut frames_skipped = 0u64;
123
+
124
+        tracing::info!(
125
+            "Starting animation: {} frames, {:.1} FPS avg",
126
+            self.scaled_frames.len(),
127
+            self.gif.average_fps()
128
+        );
129
+
130
+        loop {
131
+            // Check for stop signal
132
+            if self.stop.load(Ordering::Relaxed) {
133
+                tracing::info!("Animation stopped");
134
+                break;
135
+            }
136
+
137
+            // Handle pause
138
+            if self.paused.load(Ordering::Relaxed) {
139
+                std::thread::sleep(Duration::from_millis(50));
140
+                next_frame_time = Instant::now();
141
+                continue;
142
+            }
143
+
144
+            let now = Instant::now();
145
+
146
+            // Check if we're behind schedule
147
+            if self.config.adaptive_skip && now > next_frame_time {
148
+                let behind = now - next_frame_time;
149
+
150
+                // Skip frames if we're more than one frame behind
151
+                while next_frame_time < now {
152
+                    let frame_delay = self.gif.frames()[frame_index].delay;
153
+                    next_frame_time += frame_delay.max(min_frame_duration);
154
+                    frame_index = (frame_index + 1) % self.scaled_frames.len();
155
+                    frames_skipped += 1;
156
+                }
157
+
158
+                if frames_skipped > 0 && frames_skipped % 100 == 0 {
159
+                    tracing::debug!(
160
+                        "Skipped {} frames total ({}ms behind)",
161
+                        frames_skipped,
162
+                        behind.as_millis()
163
+                    );
164
+                }
165
+            }
166
+
167
+            // Render current frame
168
+            let scaled_frame = &self.scaled_frames[frame_index];
169
+            self.renderer.render_and_present(conn, scaled_frame)?;
170
+
171
+            // Get delay for current frame
172
+            let frame_delay = self.gif.frames()[frame_index].delay;
173
+            let actual_delay = frame_delay.max(min_frame_duration);
174
+
175
+            // Advance to next frame
176
+            frame_index = (frame_index + 1) % self.scaled_frames.len();
177
+            next_frame_time += actual_delay;
178
+
179
+            // Sleep until next frame
180
+            let sleep_time = next_frame_time.saturating_duration_since(Instant::now());
181
+            if !sleep_time.is_zero() {
182
+                std::thread::sleep(sleep_time);
183
+            }
184
+        }
185
+
186
+        Ok(())
187
+    }
188
+
189
+    /// Run a single iteration (non-blocking, for integration with async loops)
190
+    ///
191
+    /// Returns the duration to wait before the next frame.
192
+    pub fn tick(&mut self, conn: &mut Connection) -> Result<Duration> {
193
+        if self.scaled_frames.is_empty() {
194
+            return Ok(Duration::from_millis(100));
195
+        }
196
+
197
+        if self.paused.load(Ordering::Relaxed) {
198
+            return Ok(Duration::from_millis(50));
199
+        }
200
+
201
+        let frame_index = self.gif.current_index();
202
+        let scaled_frame = &self.scaled_frames[frame_index];
203
+
204
+        // Render current frame
205
+        self.renderer.render_and_present(conn, scaled_frame)?;
206
+
207
+        // Get delay for current frame
208
+        let delay = self.gif.current_frame().delay;
209
+
210
+        // Advance to next frame
211
+        self.gif.advance();
212
+
213
+        // Cap at max_fps
214
+        let min_delay = if self.config.max_fps > 0 {
215
+            Duration::from_secs_f64(1.0 / self.config.max_fps as f64)
216
+        } else {
217
+            Duration::ZERO
218
+        };
219
+
220
+        Ok(delay.max(min_delay))
221
+    }
222
+
223
+    /// Get animation info
224
+    pub fn info(&self) -> AnimationInfo {
225
+        AnimationInfo {
226
+            frame_count: self.gif.frame_count(),
227
+            current_frame: self.gif.current_index(),
228
+            total_duration: self.gif.total_duration,
229
+            average_fps: self.gif.average_fps(),
230
+            dimensions: self.gif.dimensions(),
231
+            paused: self.paused.load(Ordering::Relaxed),
232
+        }
233
+    }
234
+
235
+    /// Clean up resources
236
+    pub fn destroy(self, conn: &Connection) {
237
+        self.renderer.destroy(conn);
238
+    }
239
+}
240
+
241
+/// Information about the current animation
242
+#[derive(Debug, Clone)]
243
+pub struct AnimationInfo {
244
+    pub frame_count: usize,
245
+    pub current_frame: usize,
246
+    pub total_duration: Duration,
247
+    pub average_fps: f64,
248
+    pub dimensions: (u32, u32),
249
+    pub paused: bool,
250
+}
garbg/src/daemon/mod.rsmodified
@@ -3,5 +3,7 @@
33
 //! Runs as a background service managing wallpapers.
44
 
55
 mod state;
6
+mod animation_loop;
67
 
78
 pub use state::{Daemon, DaemonState};
9
+pub use animation_loop::{AnimationLoop, AnimationConfig, AnimationInfo};
garbg/src/daemon/state.rsmodified
@@ -1,6 +1,6 @@
11
 //! Daemon state management
22
 
3
-use anyhow::Result;
3
+use anyhow::{Context, Result};
44
 use std::collections::HashMap;
55
 use std::time::Duration;
66
 use tokio::net::UnixStream;
@@ -8,9 +8,9 @@ use tokio::net::UnixStream;
88
 use crate::config::{Config, ScaleMode};
99
 use crate::ipc::{Command, GarEvent, GarIpcClient, IpcServer, Response};
1010
 use crate::ipc::server::IpcClient;
11
-use crate::media::{scale_image, ImageLoader};
11
+use crate::media::{scale_image, AnimatedGif, ImageLoader};
1212
 use crate::state::{detect_source_type, PlaylistState};
13
-use crate::x11::Connection;
13
+use crate::x11::{AnimationRenderer, Connection};
1414
 
1515
 /// Current wallpaper state for a monitor
1616
 #[derive(Debug, Clone)]
@@ -66,6 +66,48 @@ impl DaemonState {
6666
     }
6767
 }
6868
 
69
+/// Active animation state
70
+pub struct ActiveAnimation {
71
+    /// The animated GIF data
72
+    gif: AnimatedGif,
73
+    /// Pre-scaled frames for quick rendering
74
+    scaled_frames: Vec<image::RgbaImage>,
75
+    /// Animation renderer (double-buffered)
76
+    renderer: AnimationRenderer,
77
+    /// Current frame index
78
+    current_frame: usize,
79
+    /// Max FPS
80
+    max_fps: u32,
81
+    /// Scale mode
82
+    scale_mode: ScaleMode,
83
+    /// Source URI (for status)
84
+    source: String,
85
+}
86
+
87
+impl ActiveAnimation {
88
+    /// Get the delay for the current frame
89
+    fn current_delay(&self) -> Duration {
90
+        let frame_delay = self.gif.frames()[self.current_frame].delay;
91
+        let min_delay = if self.max_fps > 0 {
92
+            Duration::from_secs_f64(1.0 / self.max_fps as f64)
93
+        } else {
94
+            Duration::ZERO
95
+        };
96
+        frame_delay.max(min_delay)
97
+    }
98
+
99
+    /// Advance to next frame, returning true if looped
100
+    fn advance(&mut self) -> bool {
101
+        self.current_frame += 1;
102
+        if self.current_frame >= self.scaled_frames.len() {
103
+            self.current_frame = 0;
104
+            true
105
+        } else {
106
+            false
107
+        }
108
+    }
109
+}
110
+
69111
 /// Main daemon struct
70112
 pub struct Daemon {
71113
     /// X11 connection
@@ -73,15 +115,35 @@ pub struct Daemon {
73115
 
74116
     /// Daemon state
75117
     state: DaemonState,
118
+
119
+    /// Current animation (if playing)
120
+    animation: Option<ActiveAnimation>,
76121
 }
77122
 
78123
 impl Daemon {
79124
     /// Create a new daemon
125
+    ///
126
+    /// Establishes X11 connection and initializes state.
127
+    /// Fails early with helpful error messages if X11 is unavailable.
80128
     pub fn new(config: Config) -> Result<Self> {
81
-        let conn = Connection::new()?;
129
+        let conn = Connection::new()
130
+            .context("Failed to initialize X11 connection for daemon")?;
131
+
132
+        // Verify connection is working
133
+        if !conn.is_alive() {
134
+            anyhow::bail!("X11 connection established but not responding");
135
+        }
136
+
137
+        let (width, height) = conn.screen_dimensions();
138
+        tracing::info!("X11 connection established (screen: {}x{})", width, height);
139
+
82140
         let state = DaemonState::new(config);
83141
 
84
-        Ok(Self { conn, state })
142
+        Ok(Self {
143
+            conn,
144
+            state,
145
+            animation: None,
146
+        })
85147
     }
86148
 
87149
     /// Run the daemon event loop
@@ -108,6 +170,9 @@ impl Daemon {
108170
             tracing::info!("Slideshow enabled: {:?} interval", interval);
109171
         }
110172
 
173
+        // Track next animation frame time
174
+        let mut next_animation_frame: Option<tokio::time::Instant> = None;
175
+
111176
         // Main event loop
112177
         loop {
113178
             tokio::select! {
@@ -121,6 +186,12 @@ impl Daemon {
121186
                             // Update slideshow timer if interval changed
122187
                             next_slideshow = self.state.slideshow_interval
123188
                                 .map(|d| tokio::time::Instant::now() + d);
189
+                            // Update animation timer if animation started
190
+                            if self.animation.is_some() && next_animation_frame.is_none() {
191
+                                next_animation_frame = Some(tokio::time::Instant::now());
192
+                            } else if self.animation.is_none() {
193
+                                next_animation_frame = None;
194
+                            }
124195
                         }
125196
                         Err(e) => {
126197
                             tracing::warn!("Accept error: {}", e);
@@ -128,10 +199,33 @@ impl Daemon {
128199
                     }
129200
                 }
130201
 
131
-                // Slideshow timer (only if enabled and not paused)
202
+                // Animation frame timer (highest priority when active)
132203
                 _ = async {
133
-                    match (next_slideshow, self.state.paused) {
134
-                        (Some(deadline), false) => {
204
+                    match (next_animation_frame, self.state.paused, &self.animation) {
205
+                        (Some(deadline), false, Some(_)) => {
206
+                            tokio::time::sleep_until(deadline).await;
207
+                        }
208
+                        _ => {
209
+                            std::future::pending::<()>().await;
210
+                        }
211
+                    }
212
+                } => {
213
+                    if let Err(e) = self.render_animation_frame() {
214
+                        tracing::warn!("Animation frame render failed: {}", e);
215
+                        // Stop animation on error
216
+                        self.animation = None;
217
+                        next_animation_frame = None;
218
+                    } else if let Some(ref anim) = self.animation {
219
+                        // Schedule next frame
220
+                        let delay = anim.current_delay();
221
+                        next_animation_frame = Some(tokio::time::Instant::now() + delay);
222
+                    }
223
+                }
224
+
225
+                // Slideshow timer (only if enabled, not paused, and no animation)
226
+                _ = async {
227
+                    match (next_slideshow, self.state.paused, &self.animation) {
228
+                        (Some(deadline), false, None) => {
135229
                             tokio::time::sleep_until(deadline).await;
136230
                         }
137231
                         _ => {
@@ -171,6 +265,21 @@ impl Daemon {
171265
         }
172266
     }
173267
 
268
+    /// Render the next animation frame
269
+    fn render_animation_frame(&mut self) -> Result<()> {
270
+        let anim = self.animation.as_mut()
271
+            .ok_or_else(|| anyhow::anyhow!("No active animation"))?;
272
+
273
+        // Render current frame
274
+        let frame = &anim.scaled_frames[anim.current_frame];
275
+        anim.renderer.render_and_present(&mut self.conn, frame)?;
276
+
277
+        // Advance to next frame
278
+        anim.advance();
279
+
280
+        Ok(())
281
+    }
282
+
174283
     /// Handle a single client connection
175284
     async fn handle_client(&mut self, stream: UnixStream) -> Result<()> {
176285
         let mut client = IpcClient::new(stream);
@@ -187,10 +296,32 @@ impl Daemon {
187296
     /// Handle an IPC command
188297
     fn handle_command(&mut self, cmd: Command) -> Response {
189298
         match cmd {
190
-            Command::Set { source, mode, monitor: _, interval_secs, shuffle } => {
299
+            Command::Set { source, mode, monitor: _, interval_secs, shuffle, animate, max_fps } => {
191300
                 let scale_mode = mode.unwrap_or(self.state.config.general.mode);
192301
 
193
-                // Set up slideshow with the new source
302
+                // Stop any existing animation first
303
+                self.animation = None;
304
+
305
+                // Check if we should animate (GIF with animate flag)
306
+                let is_gif = source.to_lowercase().ends_with(".gif")
307
+                    || source.to_lowercase().contains(".gif?")
308
+                    || source.to_lowercase().contains("/gif/");
309
+
310
+                if animate && is_gif {
311
+                    // Try to start animation
312
+                    match self.start_animation(&source, scale_mode, max_fps) {
313
+                        Ok(_) => {
314
+                            tracing::info!("Animation started: {}", source);
315
+                            return Response::ok();
316
+                        }
317
+                        Err(e) => {
318
+                            tracing::warn!("Failed to start animation: {}, falling back to static", e);
319
+                            // Fall through to static handling
320
+                        }
321
+                    }
322
+                }
323
+
324
+                // Set up slideshow with the new source (static image)
194325
                 match self.set_wallpaper_with_options(&source, scale_mode, shuffle, interval_secs) {
195326
                     Ok(_) => {
196327
                         // Update slideshow interval
@@ -332,6 +463,70 @@ impl Daemon {
332463
         self.set_wallpaper_from_source(&source, mode, shuffle)
333464
     }
334465
 
466
+    /// Start an animated GIF playback
467
+    fn start_animation(&mut self, source: &str, mode: ScaleMode, max_fps: u32) -> Result<()> {
468
+        let is_remote = source.starts_with("http://") || source.starts_with("https://");
469
+
470
+        // Load the GIF
471
+        let gif = if is_remote {
472
+            tracing::info!("Fetching remote GIF: {}", source);
473
+            let bytes = self.fetch_bytes(source)?;
474
+            AnimatedGif::load_from_bytes(&bytes)?
475
+        } else {
476
+            let expanded = shellexpand::tilde(source);
477
+            AnimatedGif::load(expanded.as_ref())?
478
+        };
479
+
480
+        if !gif.is_animated() {
481
+            anyhow::bail!("GIF is not animated (single frame)");
482
+        }
483
+
484
+        // Pre-scale all frames
485
+        let (width, height) = self.conn.screen_dimensions();
486
+        let scaled_frames: Vec<image::RgbaImage> = gif
487
+            .frames()
488
+            .iter()
489
+            .map(|frame| scale_image(&frame.image, width as u32, height as u32, mode))
490
+            .collect();
491
+
492
+        // Create animation renderer
493
+        let renderer = AnimationRenderer::new(&self.conn)?;
494
+
495
+        tracing::info!(
496
+            "Animation loaded: {} frames, {:.1} FPS",
497
+            gif.frame_count(),
498
+            gif.average_fps()
499
+        );
500
+
501
+        self.animation = Some(ActiveAnimation {
502
+            gif,
503
+            scaled_frames,
504
+            renderer,
505
+            current_frame: 0,
506
+            max_fps,
507
+            scale_mode: mode,
508
+            source: source.to_string(),
509
+        });
510
+
511
+        Ok(())
512
+    }
513
+
514
+    /// Fetch raw bytes from a URL
515
+    fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> {
516
+        let client = reqwest::blocking::Client::builder()
517
+            .user_agent("garbg/0.1")
518
+            .build()?;
519
+
520
+        let response = client.get(url).send()?;
521
+        let status = response.status();
522
+
523
+        if !status.is_success() {
524
+            anyhow::bail!("HTTP error {}: {}", status, url);
525
+        }
526
+
527
+        Ok(response.bytes()?.to_vec())
528
+    }
529
+
335530
     /// Set wallpaper with full options (used by IPC Set command)
336531
     fn set_wallpaper_with_options(
337532
         &mut self,
garbg/src/ipc/protocol.rsmodified
@@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};
44
 
55
 use crate::config::ScaleMode;
66
 
7
+/// Default max FPS for animations
8
+fn default_max_fps() -> u32 {
9
+    60
10
+}
11
+
712
 /// Commands that can be sent to the daemon
813
 #[derive(Debug, Clone, Serialize, Deserialize)]
914
 #[serde(tag = "command", rename_all = "snake_case")]
@@ -21,6 +26,12 @@ pub enum Command {
2126
         /// Shuffle the playlist
2227
         #[serde(default)]
2328
         shuffle: bool,
29
+        /// Play animated GIFs
30
+        #[serde(default)]
31
+        animate: bool,
32
+        /// Max FPS for animations (default: 60)
33
+        #[serde(default = "default_max_fps")]
34
+        max_fps: u32,
2435
     },
2536
 
2637
     /// Set wallpaper for a specific workspace
garbg/src/main.rsmodified
@@ -43,6 +43,14 @@ enum Commands {
4343
         /// Auto-rotate interval (e.g., "5m", "30s", "1h"). Process stays running.
4444
         #[arg(short, long, value_parser = parse_duration)]
4545
         interval: Option<Duration>,
46
+
47
+        /// Play animated GIFs (auto-detected by default)
48
+        #[arg(short, long)]
49
+        animate: bool,
50
+
51
+        /// Max FPS for animations (default: 60)
52
+        #[arg(long, default_value = "60")]
53
+        max_fps: u32,
4654
     },
4755
 
4856
     /// Advance to the next image in the playlist
@@ -96,8 +104,8 @@ fn main() -> Result<()> {
96104
         .init();
97105
 
98106
     match cli.command {
99
-        Commands::Set { source, mode, monitor, random, interval } => {
100
-            set_wallpaper(&source, &mode, monitor.as_deref(), random, interval)?;
107
+        Commands::Set { source, mode, monitor, random, interval, animate, max_fps } => {
108
+            set_wallpaper(&source, &mode, monitor.as_deref(), random, interval, animate, max_fps)?;
101109
         }
102110
         Commands::Next => {
103111
             cmd_next()?;
@@ -134,6 +142,8 @@ fn set_wallpaper(
134142
     _monitor: Option<&str>,
135143
     random: bool,
136144
     interval: Option<Duration>,
145
+    animate: bool,
146
+    max_fps: u32,
137147
 ) -> Result<()> {
138148
     use garbg::config::ScaleMode;
139149
     use garbg::ipc::{is_daemon_running, send_command_blocking, Command};
@@ -143,7 +153,7 @@ fn set_wallpaper(
143153
     // Normalize GitHub URLs first
144154
     let normalized_source = normalize_github_url(source);
145155
 
146
-    // If daemon is running, delegate to it (especially for interval-based rotation)
156
+    // If daemon is running, delegate to it
147157
     if is_daemon_running() {
148158
         let interval_secs = interval.map(|d| d.as_secs());
149159
 
@@ -153,6 +163,8 @@ fn set_wallpaper(
153163
             monitor: None,
154164
             interval_secs,
155165
             shuffle: random,
166
+            animate,
167
+            max_fps,
156168
         };
157169
 
158170
         let response = send_command_blocking(&cmd)?;
@@ -223,10 +235,11 @@ fn set_wallpaper(
223235
         );
224236
     }
225237
 
226
-    // Set the initial wallpaper
227
-    set_single_wallpaper(&resolved_source, scale_mode)?;
238
+    // Set the initial wallpaper (handles animated GIFs automatically)
239
+    set_single_wallpaper(&resolved_source, scale_mode, animate, max_fps)?;
228240
 
229241
     // If interval specified and daemon not running, enter foreground rotation loop
242
+    // Note: --interval mode doesn't support animations (would require stopping animation to rotate)
230243
     if let Some(interval_duration) = interval {
231244
         if let Some(mut playlist_state) = state {
232245
             tracing::info!(
@@ -244,7 +257,8 @@ fn set_wallpaper(
244257
                 let next_img = playlist_state.next().to_string();
245258
                 playlist_state.save()?;
246259
 
247
-                set_single_wallpaper(&next_img, playlist_state.mode)?;
260
+                // Static mode for rotation (animation would conflict)
261
+                set_single_wallpaper(&next_img, playlist_state.mode, false, 60)?;
248262
                 tracing::info!(
249263
                     "Rotated to [{}/{}]: {}",
250264
                     playlist_state.current_index + 1,
@@ -261,12 +275,74 @@ fn set_wallpaper(
261275
 }
262276
 
263277
 /// Set a single wallpaper (used by set, next, prev)
264
-fn set_single_wallpaper(source: &str, mode: garbg::config::ScaleMode) -> Result<()> {
265
-    use garbg::media::ImageLoader;
278
+///
279
+/// If `animate` is true and the source is an animated GIF, this will block
280
+/// and play the animation until interrupted (Ctrl+C).
281
+fn set_single_wallpaper(
282
+    source: &str,
283
+    mode: garbg::config::ScaleMode,
284
+    animate: bool,
285
+    max_fps: u32,
286
+) -> Result<()> {
287
+    use garbg::daemon::{AnimationConfig, AnimationLoop};
288
+    use garbg::media::{AnimatedGif, ImageLoader};
266289
     use garbg::x11::Connection;
267290
 
268291
     tracing::info!("Setting wallpaper: {}", source);
269292
 
293
+    // Check if source is a GIF file (by extension or URL path)
294
+    let is_gif = source.to_lowercase().ends_with(".gif")
295
+        || source.to_lowercase().contains(".gif?")  // URL with query params
296
+        || source.to_lowercase().contains("/gif/"); // Giphy-style URLs
297
+
298
+    let is_remote = source.starts_with("http://") || source.starts_with("https://");
299
+
300
+    // If it's a GIF and animation is requested, try to load as animated
301
+    if is_gif && animate {
302
+        // Load GIF data (from file or URL)
303
+        let gif_result = if is_remote {
304
+            tracing::info!("Fetching remote GIF...");
305
+            fetch_gif_from_url(source)
306
+        } else {
307
+            AnimatedGif::load(source)
308
+        };
309
+
310
+        match gif_result {
311
+            Ok(gif) if gif.is_animated() => {
312
+                // It's an animated GIF - play it
313
+                let mut conn = Connection::new()?;
314
+                let config = AnimationConfig {
315
+                    max_fps,
316
+                    adaptive_skip: true,
317
+                    scale_mode: mode,
318
+                };
319
+
320
+                let mut animation = AnimationLoop::new(gif, &conn, config)?;
321
+
322
+                tracing::info!(
323
+                    "Playing animated GIF: {} frames, {:.1} FPS (Ctrl+C to stop)",
324
+                    animation.info().frame_count,
325
+                    animation.info().average_fps
326
+                );
327
+
328
+                // Run animation (blocking until Ctrl+C)
329
+                animation.run(&mut conn)?;
330
+
331
+                // Clean up
332
+                animation.destroy(&conn);
333
+                return Ok(());
334
+            }
335
+            Ok(_) => {
336
+                // Single-frame GIF, fall through to static handling
337
+                tracing::debug!("GIF has single frame, treating as static image");
338
+            }
339
+            Err(e) => {
340
+                tracing::debug!("Failed to load as animated GIF: {}, trying as static", e);
341
+            }
342
+        }
343
+    }
344
+
345
+    // Static image handling
270346
     let mut conn = Connection::new()?;
271347
 
272348
     let image = if source.starts_with("http://") || source.starts_with("https://") {
@@ -292,7 +368,8 @@ fn cmd_next() -> Result<()> {
292368
     let next_image = state.next().to_string();
293369
     state.save()?;
294370
 
295
-    set_single_wallpaper(&next_image, state.mode)?;
371
+    // No animation for quick navigation commands
372
+    set_single_wallpaper(&next_image, state.mode, false, 60)?;
296373
 
297374
     tracing::info!(
298375
         "Next [{}/{}]: {}",
@@ -312,7 +389,8 @@ fn cmd_prev() -> Result<()> {
312389
     let prev_image = state.prev().to_string();
313390
     state.save()?;
314391
 
315
-    set_single_wallpaper(&prev_image, state.mode)?;
392
+    // No animation for quick navigation commands
393
+    set_single_wallpaper(&prev_image, state.mode, false, 60)?;
316394
 
317395
     tracing::info!(
318396
         "Prev [{}/{}]: {}",
@@ -553,6 +631,25 @@ fn fetch_image_from_url(url: &str) -> Result<image::RgbaImage> {
553631
     ImageLoader::load_bytes(&bytes, None)
554632
 }
555633
 
634
+/// Fetch an animated GIF from a URL
635
+fn fetch_gif_from_url(url: &str) -> Result<garbg::media::AnimatedGif> {
636
+    use garbg::media::AnimatedGif;
637
+
638
+    let client = reqwest::blocking::Client::builder()
639
+        .user_agent("garbg/0.1")
640
+        .build()?;
641
+
642
+    let response = client.get(url).send()?;
643
+    let status = response.status();
644
+
645
+    if !status.is_success() {
646
+        anyhow::bail!("HTTP error {}: {}", status, url);
647
+    }
648
+
649
+    let bytes = response.bytes()?;
650
+    AnimatedGif::load_from_bytes(&bytes)
651
+}
652
+
556653
 /// List images from a source and print them
557654
 fn list_images(source: &str) -> Result<()> {
558655
     let images = list_images_from_source(source)?;
garbg/src/media/gif.rsadded
@@ -0,0 +1,192 @@
1
+//! Animated GIF decoder with frame-by-frame access
2
+//!
3
+//! Provides frame extraction and timing information for animated GIFs.
4
+
5
+use anyhow::{Context, Result};
6
+use image::codecs::gif::GifDecoder;
7
+use image::{AnimationDecoder, Frame, RgbaImage};
8
+use std::fs;
9
+use std::io::{BufRead, Cursor, Seek};
10
+use std::path::Path;
11
+use std::time::Duration;
12
+
13
+/// A single animation frame with timing information
14
+#[derive(Debug, Clone)]
15
+pub struct AnimationFrame {
16
+    /// The frame image data
17
+    pub image: RgbaImage,
18
+    /// Delay before showing the next frame
19
+    pub delay: Duration,
20
+}
21
+
22
+/// Decoded animated GIF with all frames
23
+pub struct AnimatedGif {
24
+    /// All frames in order
25
+    frames: Vec<AnimationFrame>,
26
+    /// Current frame index
27
+    current_index: usize,
28
+    /// Whether to loop forever
29
+    pub loops: bool,
30
+    /// Total duration of one loop
31
+    pub total_duration: Duration,
32
+}
33
+
34
+impl AnimatedGif {
35
+    /// Load an animated GIF from a file path
36
+    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
37
+        let path = path.as_ref();
38
+        let data = fs::read(path)
39
+            .with_context(|| format!("Failed to read GIF: {}", path.display()))?;
40
+
41
+        Self::load_from_bytes(&data)
42
+    }
43
+
44
+    /// Load an animated GIF from bytes
45
+    pub fn load_from_bytes(data: &[u8]) -> Result<Self> {
46
+        // Use Cursor which implements BufRead + Seek
47
+        let cursor = Cursor::new(data.to_vec());
48
+        Self::load_from_reader(cursor)
49
+    }
50
+
51
+    /// Load from any reader that supports BufRead + Seek
52
+    fn load_from_reader<R: BufRead + Seek>(reader: R) -> Result<Self> {
53
+        let decoder = GifDecoder::new(reader)
54
+            .context("Failed to create GIF decoder")?;
55
+
56
+        let raw_frames = decoder.into_frames();
57
+        let mut frames = Vec::new();
58
+        let mut total_duration = Duration::ZERO;
59
+
60
+        for frame_result in raw_frames {
61
+            let frame: Frame = frame_result.context("Failed to decode GIF frame")?;
62
+            let delay = frame_delay_to_duration(&frame);
63
+
64
+            // GIFs with 0 delay often mean "as fast as possible"
65
+            // Default to 100ms (10 fps) for reasonable playback
66
+            let delay = if delay.is_zero() {
67
+                Duration::from_millis(100)
68
+            } else {
69
+                delay
70
+            };
71
+
72
+            total_duration += delay;
73
+
74
+            frames.push(AnimationFrame {
75
+                image: frame.into_buffer(),
76
+                delay,
77
+            });
78
+        }
79
+
80
+        if frames.is_empty() {
81
+            anyhow::bail!("GIF contains no frames");
82
+        }
83
+
84
+        Ok(Self {
85
+            frames,
86
+            current_index: 0,
87
+            loops: true,
88
+            total_duration,
89
+        })
90
+    }
91
+
92
+    /// Get the number of frames
93
+    pub fn frame_count(&self) -> usize {
94
+        self.frames.len()
95
+    }
96
+
97
+    /// Check if this is actually animated (more than one frame)
98
+    pub fn is_animated(&self) -> bool {
99
+        self.frames.len() > 1
100
+    }
101
+
102
+    /// Get the current frame
103
+    pub fn current_frame(&self) -> &AnimationFrame {
104
+        &self.frames[self.current_index]
105
+    }
106
+
107
+    /// Get a specific frame by index
108
+    pub fn frame(&self, index: usize) -> Option<&AnimationFrame> {
109
+        self.frames.get(index)
110
+    }
111
+
112
+    /// Get the current frame index
113
+    pub fn current_index(&self) -> usize {
114
+        self.current_index
115
+    }
116
+
117
+    /// Advance to the next frame, returning true if we looped
118
+    pub fn advance(&mut self) -> bool {
119
+        self.current_index += 1;
120
+        if self.current_index >= self.frames.len() {
121
+            self.current_index = 0;
122
+            true // Looped
123
+        } else {
124
+            false
125
+        }
126
+    }
127
+
128
+    /// Go back to the previous frame
129
+    pub fn rewind(&mut self) -> bool {
130
+        if self.current_index == 0 {
131
+            self.current_index = self.frames.len() - 1;
132
+            true // Looped
133
+        } else {
134
+            self.current_index -= 1;
135
+            false
136
+        }
137
+    }
138
+
139
+    /// Reset to the first frame
140
+    pub fn reset(&mut self) {
141
+        self.current_index = 0;
142
+    }
143
+
144
+    /// Get all frames as a slice
145
+    pub fn frames(&self) -> &[AnimationFrame] {
146
+        &self.frames
147
+    }
148
+
149
+    /// Get average FPS
150
+    pub fn average_fps(&self) -> f64 {
151
+        if self.total_duration.is_zero() {
152
+            return 0.0;
153
+        }
154
+        self.frames.len() as f64 / self.total_duration.as_secs_f64()
155
+    }
156
+
157
+    /// Get dimensions (width, height) from first frame
158
+    pub fn dimensions(&self) -> (u32, u32) {
159
+        let first = &self.frames[0].image;
160
+        (first.width(), first.height())
161
+    }
162
+}
163
+
164
+/// Convert frame delay ratio to Duration
165
+fn frame_delay_to_duration(frame: &Frame) -> Duration {
166
+    let (numerator, denominator) = frame.delay().numer_denom_ms();
167
+    if denominator == 0 {
168
+        Duration::ZERO
169
+    } else {
170
+        Duration::from_millis((numerator as u64 * 1000) / denominator as u64)
171
+    }
172
+}
173
+
174
+/// Check if a file is likely an animated GIF (has multiple frames)
175
+pub fn is_animated_gif<P: AsRef<Path>>(path: P) -> bool {
176
+    // Quick check: try to load and see if it has multiple frames
177
+    match AnimatedGif::load(path) {
178
+        Ok(gif) => gif.is_animated(),
179
+        Err(_) => false,
180
+    }
181
+}
182
+
183
+#[cfg(test)]
184
+mod tests {
185
+    use super::*;
186
+
187
+    #[test]
188
+    fn test_frame_delay_conversion() {
189
+        // A frame with 100ms delay (10 centiseconds)
190
+        // GIF delays are typically in centiseconds
191
+    }
192
+}
garbg/src/media/mod.rsmodified
@@ -4,9 +4,11 @@
44
 
55
 mod loader;
66
 mod scaler;
7
+mod gif;
78
 
89
 pub use loader::ImageLoader;
910
 pub use scaler::scale_image;
11
+pub use gif::{AnimatedGif, AnimationFrame, is_animated_gif};
1012
 
1113
 // Re-export ScaleMode from config for convenience
1214
 pub use crate::config::ScaleMode;
garbg/src/x11/animation.rsadded
@@ -0,0 +1,201 @@
1
+//! Animation support with double buffering
2
+//!
3
+//! Provides smooth animation playback using double-buffered pixmaps.
4
+
5
+use anyhow::Result;
6
+use x11rb::protocol::xproto::*;
7
+use x11rb::connection::Connection as X11Connection;
8
+use x11rb::wrapper::ConnectionExt as _;
9
+
10
+use super::Connection;
11
+
12
+/// Double buffer for smooth animation rendering
13
+pub struct DoubleBuffer {
14
+    /// Front buffer (currently displayed)
15
+    front: Pixmap,
16
+    /// Back buffer (being rendered to)
17
+    back: Pixmap,
18
+    /// Screen dimensions
19
+    width: u16,
20
+    height: u16,
21
+}
22
+
23
+impl DoubleBuffer {
24
+    /// Create a new double buffer with the given dimensions
25
+    pub fn new(conn: &Connection) -> Result<Self> {
26
+        let (width, height) = conn.screen_dimensions();
27
+        let depth = conn.depth();
28
+        let root = conn.root();
29
+        let x11_conn = conn.conn();
30
+
31
+        // Create front buffer
32
+        let front = x11_conn.generate_id()?;
33
+        x11_conn.create_pixmap(depth, front, root, width, height)?;
34
+
35
+        // Create back buffer
36
+        let back = x11_conn.generate_id()?;
37
+        x11_conn.create_pixmap(depth, back, root, width, height)?;
38
+
39
+        Ok(Self {
40
+            front,
41
+            back,
42
+            width,
43
+            height,
44
+        })
45
+    }
46
+
47
+    /// Get the back buffer to render to
48
+    pub fn back_buffer(&self) -> Pixmap {
49
+        self.back
50
+    }
51
+
52
+    /// Get the front buffer (currently displayed)
53
+    pub fn front_buffer(&self) -> Pixmap {
54
+        self.front
55
+    }
56
+
57
+    /// Swap front and back buffers
58
+    pub fn swap(&mut self) {
59
+        std::mem::swap(&mut self.front, &mut self.back);
60
+    }
61
+
62
+    /// Get dimensions
63
+    pub fn dimensions(&self) -> (u16, u16) {
64
+        (self.width, self.height)
65
+    }
66
+
67
+    /// Free the pixmaps
68
+    pub fn destroy(&self, conn: &Connection) {
69
+        let x11_conn = conn.conn();
70
+        let _ = x11_conn.free_pixmap(self.front);
71
+        let _ = x11_conn.free_pixmap(self.back);
72
+    }
73
+}
74
+
75
+/// Animation renderer using double buffering
76
+pub struct AnimationRenderer {
77
+    /// Double buffer for smooth rendering
78
+    buffer: DoubleBuffer,
79
+}
80
+
81
+impl AnimationRenderer {
82
+    /// Create a new animation renderer
83
+    pub fn new(conn: &Connection) -> Result<Self> {
84
+        let buffer = DoubleBuffer::new(conn)?;
85
+        Ok(Self { buffer })
86
+    }
87
+
88
+    /// Render a frame to the back buffer
89
+    pub fn render_frame(&mut self, conn: &mut Connection, frame: &image::RgbaImage) -> Result<()> {
90
+        let (width, height) = self.buffer.dimensions();
91
+
92
+        // Convert RGBA to BGRA (X11 native format)
93
+        let bgra_data = rgba_to_bgra(frame);
94
+
95
+        let gc = conn.gc();
96
+        let x11_conn = conn.conn();
97
+        let depth = conn.depth();
98
+        let back = self.buffer.back_buffer();
99
+
100
+        // Calculate chunking for large images
101
+        let max_request_bytes = x11_conn.setup().maximum_request_length as usize * 4;
102
+        let bytes_per_row = width as usize * 4;
103
+        let request_overhead = 28;
104
+        let max_rows_per_request = ((max_request_bytes - request_overhead) / bytes_per_row).max(1) as u16;
105
+
106
+        // Send image in chunks
107
+        let mut y_offset: u16 = 0;
108
+        while y_offset < height {
109
+            let rows_to_send = (height - y_offset).min(max_rows_per_request);
110
+            let start_byte = y_offset as usize * bytes_per_row;
111
+            let end_byte = (y_offset as usize + rows_to_send as usize) * bytes_per_row;
112
+            let chunk = &bgra_data[start_byte..end_byte];
113
+
114
+            x11_conn.put_image(
115
+                ImageFormat::Z_PIXMAP,
116
+                back,
117
+                gc,
118
+                width,
119
+                rows_to_send,
120
+                0,
121
+                y_offset as i16,
122
+                0,
123
+                depth,
124
+                chunk,
125
+            )?;
126
+
127
+            y_offset += rows_to_send;
128
+        }
129
+
130
+        Ok(())
131
+    }
132
+
133
+    /// Present the back buffer (swap and display)
134
+    pub fn present(&mut self, conn: &mut Connection) -> Result<()> {
135
+        // Swap buffers
136
+        self.buffer.swap();
137
+
138
+        // Set the new front buffer as root background
139
+        let front = self.buffer.front_buffer();
140
+        let root = conn.root();
141
+        let x11_conn = conn.conn();
142
+        let atoms = conn.atoms();
143
+
144
+        // Set the standard atoms for compatibility
145
+        x11_conn.change_property32(
146
+            PropMode::REPLACE,
147
+            root,
148
+            atoms.xrootpmap_id,
149
+            AtomEnum::PIXMAP,
150
+            &[front],
151
+        )?;
152
+
153
+        x11_conn.change_property32(
154
+            PropMode::REPLACE,
155
+            root,
156
+            atoms.esetroot_pmap_id,
157
+            AtomEnum::PIXMAP,
158
+            &[front],
159
+        )?;
160
+
161
+        // Set as background and clear
162
+        x11_conn.change_window_attributes(
163
+            root,
164
+            &ChangeWindowAttributesAux::new().background_pixmap(front),
165
+        )?;
166
+
167
+        let (width, height) = self.buffer.dimensions();
168
+        x11_conn.clear_area(false, root, 0, 0, width, height)?;
169
+        x11_conn.flush()?;
170
+
171
+        Ok(())
172
+    }
173
+
174
+    /// Render and present in one call
175
+    pub fn render_and_present(&mut self, conn: &mut Connection, frame: &image::RgbaImage) -> Result<()> {
176
+        self.render_frame(conn, frame)?;
177
+        self.present(conn)
178
+    }
179
+
180
+    /// Get dimensions
181
+    pub fn dimensions(&self) -> (u16, u16) {
182
+        self.buffer.dimensions()
183
+    }
184
+
185
+    /// Clean up resources
186
+    pub fn destroy(self, conn: &Connection) {
187
+        self.buffer.destroy(conn);
188
+    }
189
+}
190
+
191
+/// Convert RGBA to BGRA (X11 native format for 32-bit visuals)
192
+fn rgba_to_bgra(image: &image::RgbaImage) -> Vec<u8> {
193
+    let mut bgra = Vec::with_capacity(image.len());
194
+    for pixel in image.pixels() {
195
+        bgra.push(pixel[2]); // B
196
+        bgra.push(pixel[1]); // G
197
+        bgra.push(pixel[0]); // R
198
+        bgra.push(pixel[3]); // A
199
+    }
200
+    bgra
201
+}
garbg/src/x11/connection.rsmodified
@@ -6,6 +6,26 @@ use x11rb::protocol::xproto::*;
66
 use x11rb::rust_connection::RustConnection;
77
 use x11rb::wrapper::ConnectionExt as _;
88
 
9
+/// X11 connection errors with helpful messages
10
+#[derive(Debug, thiserror::Error)]
11
+pub enum X11Error {
12
+    #[error("DISPLAY environment variable not set. Is an X11 server running?")]
13
+    NoDisplay,
14
+
15
+    #[error("Failed to connect to X server at '{display}': {source}. Is the X server running?")]
16
+    ConnectionFailed {
17
+        display: String,
18
+        #[source]
19
+        source: x11rb::errors::ConnectError,
20
+    },
21
+
22
+    #[error("X11 operation failed: {0}")]
23
+    Protocol(#[from] x11rb::errors::ConnectionError),
24
+
25
+    #[error("X11 reply error: {0}")]
26
+    Reply(#[from] x11rb::errors::ReplyError),
27
+}
28
+
929
 /// Interned X11 atoms for wallpaper operations
1030
 pub struct Atoms {
1131
     /// Standard atom for root pixmap (used by many apps)
@@ -50,9 +70,26 @@ pub struct Connection {
5070
 
5171
 impl Connection {
5272
     /// Create a new X11 connection
73
+    ///
74
+    /// Returns helpful error messages if X11 is not available:
75
+    /// - Checks for DISPLAY environment variable
76
+    /// - Provides actionable error messages for common failures
5377
     pub fn new() -> Result<Self> {
54
-        let (conn, screen_num) = RustConnection::connect(None)
55
-            .context("Failed to connect to X server")?;
78
+        // Check DISPLAY environment variable first for a better error message
79
+        let display = std::env::var("DISPLAY").ok();
80
+        if display.is_none() {
81
+            return Err(X11Error::NoDisplay.into());
82
+        }
83
+
84
+        let (conn, screen_num) = match RustConnection::connect(None) {
85
+            Ok(result) => result,
86
+            Err(e) => {
87
+                return Err(X11Error::ConnectionFailed {
88
+                    display: display.unwrap_or_else(|| "unknown".to_string()),
89
+                    source: e,
90
+                }.into());
91
+            }
92
+        };
5693
 
5794
         let screen = &conn.setup().roots[screen_num];
5895
         let root = screen.root;
@@ -113,6 +150,16 @@ impl Connection {
113150
         &self.atoms
114151
     }
115152
 
153
+    /// Check if the X11 connection is still alive
154
+    ///
155
+    /// Performs a round-trip to the X server to verify connectivity.
156
+    /// Returns false if the connection is broken.
157
+    pub fn is_alive(&self) -> bool {
158
+        // GetInputFocus is a cheap round-trip to verify connection health
159
+        self.conn.get_input_focus().is_ok()
160
+            && self.conn.sync().is_ok()
161
+    }
162
+
116163
     /// Set a wallpaper from BGRA image data
117164
     pub fn set_wallpaper(&mut self, image: &image::RgbaImage) -> Result<()> {
118165
         let (width, height) = self.screen_dimensions();
garbg/src/x11/mod.rsmodified
@@ -6,7 +6,9 @@
66
 mod connection;
77
 mod renderer;
88
 mod monitors;
9
+mod animation;
910
 
10
-pub use connection::Connection;
11
+pub use connection::{Connection, X11Error};
1112
 pub use renderer::Renderer;
1213
 pub use monitors::Monitor;
14
+pub use animation::{AnimationRenderer, DoubleBuffer};