markdown · 13977 bytes Raw Blame History

Sprint 4: garbg Integration

Goal: Integrate with garbg to display the same wallpaper that will be shown after login, creating a seamless visual transition.

Objectives

  • Read garbg configuration to determine wallpaper source
  • Read garbg playlist state for current wallpaper
  • Apply consistent blur settings
  • Ensure seamless transition from greeter to gar session
  • Handle fallback when garbg config not available

Background: Seamless Transition

The goal is that when a user logs in, the transition from greeter to desktop should feel smooth:

┌─────────────────────────────────────────────────────────────────┐
│ Greeter                                                         │
│ - Shows blurred version of current garbg wallpaper              │
│ - User enters credentials                                       │
│ - Login succeeds                                                │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼ (fade out greeter)
┌─────────────────────────────────────────────────────────────────┐
│ gar Session                                                     │
│ - garbg starts and sets SAME wallpaper (unblurred)              │
│ - Only visible change: blur fades away, UI elements appear      │
└─────────────────────────────────────────────────────────────────┘

Tasks

4.1 Read garbg Configuration

// gardm-greeter/src/garbg.rs

use serde::Deserialize;
use std::path::PathBuf;

#[derive(Debug, Deserialize)]
pub struct GarbgConfig {
    #[serde(default)]
    pub general: GeneralConfig,
    #[serde(default)]
    pub default: DefaultConfig,
}

#[derive(Debug, Deserialize, Default)]
pub struct GeneralConfig {
    #[serde(default = "default_mode")]
    pub mode: String,
}

#[derive(Debug, Deserialize, Default)]
pub struct DefaultConfig {
    #[serde(default)]
    pub source: String,
}

fn default_mode() -> String { "fill".to_string() }

impl GarbgConfig {
    /// Load garbg config from user or system location
    pub fn load(username: Option<&str>) -> Option<Self> {
        // Try user config first (if we know the username)
        if let Some(user) = username {
            if let Some(home) = get_user_home(user) {
                let user_config = home.join(".config/garbg/config.toml");
                if let Ok(content) = std::fs::read_to_string(&user_config) {
                    if let Ok(config) = toml::from_str(&content) {
                        return Some(config);
                    }
                }
            }
        }

        // Try system-wide default
        let system_config = PathBuf::from("/etc/garbg/config.toml");
        if let Ok(content) = std::fs::read_to_string(&system_config) {
            if let Ok(config) = toml::from_str(&content) {
                return Some(config);
            }
        }

        None
    }

    /// Get the default wallpaper path
    pub fn default_wallpaper(&self) -> Option<String> {
        if self.default.source.is_empty() {
            None
        } else {
            Some(expand_path(&self.default.source))
        }
    }
}

fn get_user_home(username: &str) -> Option<PathBuf> {
    use nix::unistd::User;
    User::from_name(username).ok()?
        .map(|u| PathBuf::from(u.dir))
}

fn expand_path(path: &str) -> String {
    shellexpand::tilde(path).to_string()
}

4.2 Read garbg Playlist State

// gardm-greeter/src/garbg.rs (continued)

#[derive(Debug, Deserialize)]
pub struct PlaylistState {
    pub source: String,
    pub images: Vec<String>,
    pub current_index: usize,
    #[serde(default)]
    pub shuffled: bool,
}

impl PlaylistState {
    /// Load current playlist state from runtime directory
    pub fn load(username: Option<&str>) -> Option<Self> {
        // Playlist state is stored in XDG_RUNTIME_DIR
        let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?;
        let state_file = PathBuf::from(&runtime_dir).join("garbg-state.json");

        // If that doesn't exist, try user-specific location
        if !state_file.exists() {
            if let Some(user) = username {
                // Try /run/user/<uid>/garbg-state.json
                if let Some(uid) = get_user_uid(user) {
                    let user_runtime = format!("/run/user/{}/garbg-state.json", uid);
                    if let Ok(content) = std::fs::read_to_string(&user_runtime) {
                        if let Ok(state) = serde_json::from_str(&content) {
                            return Some(state);
                        }
                    }
                }
            }
        }

        let content = std::fs::read_to_string(&state_file).ok()?;
        serde_json::from_str(&content).ok()
    }

    /// Get the current wallpaper path
    pub fn current_wallpaper(&self) -> Option<&str> {
        self.images.get(self.current_index).map(|s| s.as_str())
    }
}

fn get_user_uid(username: &str) -> Option<u32> {
    use nix::unistd::User;
    User::from_name(username).ok()?
        .map(|u| u.uid.as_raw())
}

4.3 Wallpaper Resolution Logic

// gardm-greeter/src/garbg.rs (continued)

/// Resolve which wallpaper to use for the greeter
pub struct WallpaperResolver {
    fallback_path: String,
}

impl WallpaperResolver {
    pub fn new(fallback: &str) -> Self {
        Self {
            fallback_path: fallback.to_string(),
        }
    }

    /// Resolve wallpaper path, trying multiple sources
    pub fn resolve(&self, username: Option<&str>) -> String {
        // Priority 1: Current playlist state (what user was last seeing)
        if let Some(state) = PlaylistState::load(username) {
            if let Some(current) = state.current_wallpaper() {
                let expanded = expand_path(current);
                if std::path::Path::new(&expanded).exists() {
                    tracing::info!("Using wallpaper from playlist: {}", expanded);
                    return expanded;
                }
            }
        }

        // Priority 2: garbg config default
        if let Some(config) = GarbgConfig::load(username) {
            if let Some(default) = config.default_wallpaper() {
                // If it's a directory, pick first image
                let path = std::path::Path::new(&default);
                if path.is_dir() {
                    if let Some(first) = first_image_in_dir(&default) {
                        tracing::info!("Using first image from garbg source dir: {}", first);
                        return first;
                    }
                } else if path.exists() {
                    tracing::info!("Using wallpaper from garbg config: {}", default);
                    return default;
                }
            }
        }

        // Priority 3: Fallback
        tracing::info!("Using fallback wallpaper: {}", self.fallback_path);
        self.fallback_path.clone()
    }
}

/// Get first image file in a directory
fn first_image_in_dir(dir: &str) -> Option<String> {
    let extensions = ["jpg", "jpeg", "png", "webp"];

    let mut entries: Vec<_> = std::fs::read_dir(dir).ok()?
        .filter_map(|e| e.ok())
        .filter(|e| {
            let path = e.path();
            if let Some(ext) = path.extension() {
                extensions.contains(&ext.to_string_lossy().to_lowercase().as_str())
            } else {
                false
            }
        })
        .collect();

    entries.sort_by_key(|e| e.path());
    entries.first().map(|e| e.path().to_string_lossy().to_string())
}

4.4 Shared Visual Settings

// gardm-greeter/src/config.rs

use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct GreeterConfig {
    #[serde(default)]
    pub visual: VisualConfig,
    #[serde(default)]
    pub garbg: GarbgIntegration,
}

#[derive(Debug, Deserialize, Default)]
pub struct VisualConfig {
    /// Blur radius for background (should match gar lock screen)
    #[serde(default = "default_blur_radius")]
    pub blur_radius: f32,

    /// Background brightness (0.0-1.0, lower = darker)
    #[serde(default = "default_brightness")]
    pub blur_brightness: f32,

    /// Corner radius for UI elements (match gar's corner_radius)
    #[serde(default = "default_corner_radius")]
    pub corner_radius: f64,
}

#[derive(Debug, Deserialize, Default)]
pub struct GarbgIntegration {
    /// Whether to use garbg wallpaper
    #[serde(default = "default_true")]
    pub enabled: bool,

    /// Fallback wallpaper if garbg not configured
    #[serde(default = "default_fallback")]
    pub fallback: String,
}

fn default_blur_radius() -> f32 { 20.0 }
fn default_brightness() -> f32 { 0.7 }
fn default_corner_radius() -> f64 { 18.0 }
fn default_true() -> bool { true }
fn default_fallback() -> String { "/usr/share/gardm/backgrounds/default.jpg".to_string() }

4.5 Integration with Greeter Main Loop

// gardm-greeter/src/main.rs (updated)

fn main() -> anyhow::Result<()> {
    let config = GreeterConfig::load()?;
    let window = GreeterWindow::new()?;

    // Resolve wallpaper using garbg integration
    let wallpaper_path = if config.garbg.enabled {
        let resolver = WallpaperResolver::new(&config.garbg.fallback);
        // Initially no username known, use system defaults
        resolver.resolve(None)
    } else {
        config.garbg.fallback.clone()
    };

    // Load and blur background
    let background = load_blurred_background(
        &wallpaper_path,
        window.width() as u32,
        window.height() as u32,
        config.visual.blur_radius,
        config.visual.blur_brightness,
    )?;

    let mut form = LoginForm::new(
        window.width() as f64,
        window.height() as f64,
        config.visual.corner_radius,
    );

    // When username is entered, potentially update wallpaper
    // to user-specific one (optional enhancement)

    // ... rest of event loop ...
}

4.6 Session Transition Effect

// gardm-greeter/src/transition.rs

use std::time::{Duration, Instant};

/// Fade out transition before starting session
pub struct FadeOutTransition {
    start_time: Instant,
    duration: Duration,
}

impl FadeOutTransition {
    pub fn new(duration_ms: u64) -> Self {
        Self {
            start_time: Instant::now(),
            duration: Duration::from_millis(duration_ms),
        }
    }

    /// Get current opacity (1.0 -> 0.0)
    pub fn opacity(&self) -> f64 {
        let elapsed = self.start_time.elapsed();
        if elapsed >= self.duration {
            0.0
        } else {
            1.0 - (elapsed.as_secs_f64() / self.duration.as_secs_f64())
        }
    }

    pub fn is_complete(&self) -> bool {
        self.start_time.elapsed() >= self.duration
    }
}

/// Apply fade during session start
pub fn render_with_fade(
    ctx: &cairo::Context,
    background: &image::RgbaImage,
    form: &LoginForm,
    fade: Option<&FadeOutTransition>,
) -> anyhow::Result<()> {
    // Render background (always full opacity)
    render_background(ctx, background)?;

    // Render UI with fade
    if let Some(fade) = fade {
        ctx.push_group();
        form.render(ctx, &pango_ctx)?;
        ctx.pop_group_to_source()?;
        ctx.paint_with_alpha(fade.opacity())?;
    } else {
        form.render(ctx, &pango_ctx)?;
    }

    Ok(())
}

Acceptance Criteria

  1. Greeter shows same wallpaper as garbg will after login
  2. Blur settings are consistent with gar lock screen
  3. Fallback works when garbg is not configured
  4. User-specific wallpaper is used when username is known
  5. Smooth fade transition when starting session
  6. No flash of different wallpaper during login

Pitfalls to Avoid

  1. File permissions - greeter runs as gardm user, may not access user home
  2. Race conditions - garbg state file might be stale
  3. Directory vs file - garbg source might be a directory
  4. Missing files - wallpaper might have been deleted
  5. Large images - blur on 4K images is slow, cache if needed

Testing

# Test wallpaper resolution
# 1. With garbg configured and running
garbg set ~/Pictures/wallpapers --random
DISPLAY=:1 ./target/release/gardm-greeter

# 2. With garbg config but no playlist
rm ~/.local/state/garbg-state.json
DISPLAY=:1 ./target/release/gardm-greeter

# 3. Without any garbg config (should use fallback)
mv ~/.config/garbg/config.toml ~/.config/garbg/config.toml.bak
DISPLAY=:1 ./target/release/gardm-greeter

Dependencies for This Sprint

# gardm-greeter/Cargo.toml
[dependencies]
shellexpand = "3.0"
toml = "0.8"
serde_json = "1.0"

Integration Checklist

  • Greeter reads ~/.config/garbg/config.toml
  • Greeter reads $XDG_RUNTIME_DIR/garbg-state.json
  • Blur radius matches gar lock screen (if implemented)
  • Corner radius matches gar's corner_radius setting
  • Fallback image is bundled with gardm package
  • Fade transition timing feels smooth (150-200ms)

Future Enhancements

  • Per-user wallpaper preview before login
  • Animated wallpaper support (match garbg animation)
  • Workspace-specific wallpaper from garbg config
  • Live wallpaper update if garbg changes during greeter

Next Steps

After Sprint 4, consider:

  • Sprint 5: Power buttons and session selector
  • Sprint 6: User list with avatars
  • Sprint 7: Accessibility and theming
  • Sprint 8: Multi-monitor support
View source
1 # Sprint 4: garbg Integration
2
3 **Goal:** Integrate with garbg to display the same wallpaper that will be shown after login, creating a seamless visual transition.
4
5 ## Objectives
6
7 - Read garbg configuration to determine wallpaper source
8 - Read garbg playlist state for current wallpaper
9 - Apply consistent blur settings
10 - Ensure seamless transition from greeter to gar session
11 - Handle fallback when garbg config not available
12
13 ## Background: Seamless Transition
14
15 The goal is that when a user logs in, the transition from greeter to desktop should feel smooth:
16
17 ```
18 ┌─────────────────────────────────────────────────────────────────┐
19 │ Greeter │
20 │ - Shows blurred version of current garbg wallpaper │
21 │ - User enters credentials │
22 │ - Login succeeds │
23 └─────────────────────────────────────────────────────────────────┘
24
25 ▼ (fade out greeter)
26 ┌─────────────────────────────────────────────────────────────────┐
27 │ gar Session │
28 │ - garbg starts and sets SAME wallpaper (unblurred) │
29 │ - Only visible change: blur fades away, UI elements appear │
30 └─────────────────────────────────────────────────────────────────┘
31 ```
32
33 ## Tasks
34
35 ### 4.1 Read garbg Configuration
36
37 ```rust
38 // gardm-greeter/src/garbg.rs
39
40 use serde::Deserialize;
41 use std::path::PathBuf;
42
43 #[derive(Debug, Deserialize)]
44 pub struct GarbgConfig {
45 #[serde(default)]
46 pub general: GeneralConfig,
47 #[serde(default)]
48 pub default: DefaultConfig,
49 }
50
51 #[derive(Debug, Deserialize, Default)]
52 pub struct GeneralConfig {
53 #[serde(default = "default_mode")]
54 pub mode: String,
55 }
56
57 #[derive(Debug, Deserialize, Default)]
58 pub struct DefaultConfig {
59 #[serde(default)]
60 pub source: String,
61 }
62
63 fn default_mode() -> String { "fill".to_string() }
64
65 impl GarbgConfig {
66 /// Load garbg config from user or system location
67 pub fn load(username: Option<&str>) -> Option<Self> {
68 // Try user config first (if we know the username)
69 if let Some(user) = username {
70 if let Some(home) = get_user_home(user) {
71 let user_config = home.join(".config/garbg/config.toml");
72 if let Ok(content) = std::fs::read_to_string(&user_config) {
73 if let Ok(config) = toml::from_str(&content) {
74 return Some(config);
75 }
76 }
77 }
78 }
79
80 // Try system-wide default
81 let system_config = PathBuf::from("/etc/garbg/config.toml");
82 if let Ok(content) = std::fs::read_to_string(&system_config) {
83 if let Ok(config) = toml::from_str(&content) {
84 return Some(config);
85 }
86 }
87
88 None
89 }
90
91 /// Get the default wallpaper path
92 pub fn default_wallpaper(&self) -> Option<String> {
93 if self.default.source.is_empty() {
94 None
95 } else {
96 Some(expand_path(&self.default.source))
97 }
98 }
99 }
100
101 fn get_user_home(username: &str) -> Option<PathBuf> {
102 use nix::unistd::User;
103 User::from_name(username).ok()?
104 .map(|u| PathBuf::from(u.dir))
105 }
106
107 fn expand_path(path: &str) -> String {
108 shellexpand::tilde(path).to_string()
109 }
110 ```
111
112 ### 4.2 Read garbg Playlist State
113
114 ```rust
115 // gardm-greeter/src/garbg.rs (continued)
116
117 #[derive(Debug, Deserialize)]
118 pub struct PlaylistState {
119 pub source: String,
120 pub images: Vec<String>,
121 pub current_index: usize,
122 #[serde(default)]
123 pub shuffled: bool,
124 }
125
126 impl PlaylistState {
127 /// Load current playlist state from runtime directory
128 pub fn load(username: Option<&str>) -> Option<Self> {
129 // Playlist state is stored in XDG_RUNTIME_DIR
130 let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?;
131 let state_file = PathBuf::from(&runtime_dir).join("garbg-state.json");
132
133 // If that doesn't exist, try user-specific location
134 if !state_file.exists() {
135 if let Some(user) = username {
136 // Try /run/user/<uid>/garbg-state.json
137 if let Some(uid) = get_user_uid(user) {
138 let user_runtime = format!("/run/user/{}/garbg-state.json", uid);
139 if let Ok(content) = std::fs::read_to_string(&user_runtime) {
140 if let Ok(state) = serde_json::from_str(&content) {
141 return Some(state);
142 }
143 }
144 }
145 }
146 }
147
148 let content = std::fs::read_to_string(&state_file).ok()?;
149 serde_json::from_str(&content).ok()
150 }
151
152 /// Get the current wallpaper path
153 pub fn current_wallpaper(&self) -> Option<&str> {
154 self.images.get(self.current_index).map(|s| s.as_str())
155 }
156 }
157
158 fn get_user_uid(username: &str) -> Option<u32> {
159 use nix::unistd::User;
160 User::from_name(username).ok()?
161 .map(|u| u.uid.as_raw())
162 }
163 ```
164
165 ### 4.3 Wallpaper Resolution Logic
166
167 ```rust
168 // gardm-greeter/src/garbg.rs (continued)
169
170 /// Resolve which wallpaper to use for the greeter
171 pub struct WallpaperResolver {
172 fallback_path: String,
173 }
174
175 impl WallpaperResolver {
176 pub fn new(fallback: &str) -> Self {
177 Self {
178 fallback_path: fallback.to_string(),
179 }
180 }
181
182 /// Resolve wallpaper path, trying multiple sources
183 pub fn resolve(&self, username: Option<&str>) -> String {
184 // Priority 1: Current playlist state (what user was last seeing)
185 if let Some(state) = PlaylistState::load(username) {
186 if let Some(current) = state.current_wallpaper() {
187 let expanded = expand_path(current);
188 if std::path::Path::new(&expanded).exists() {
189 tracing::info!("Using wallpaper from playlist: {}", expanded);
190 return expanded;
191 }
192 }
193 }
194
195 // Priority 2: garbg config default
196 if let Some(config) = GarbgConfig::load(username) {
197 if let Some(default) = config.default_wallpaper() {
198 // If it's a directory, pick first image
199 let path = std::path::Path::new(&default);
200 if path.is_dir() {
201 if let Some(first) = first_image_in_dir(&default) {
202 tracing::info!("Using first image from garbg source dir: {}", first);
203 return first;
204 }
205 } else if path.exists() {
206 tracing::info!("Using wallpaper from garbg config: {}", default);
207 return default;
208 }
209 }
210 }
211
212 // Priority 3: Fallback
213 tracing::info!("Using fallback wallpaper: {}", self.fallback_path);
214 self.fallback_path.clone()
215 }
216 }
217
218 /// Get first image file in a directory
219 fn first_image_in_dir(dir: &str) -> Option<String> {
220 let extensions = ["jpg", "jpeg", "png", "webp"];
221
222 let mut entries: Vec<_> = std::fs::read_dir(dir).ok()?
223 .filter_map(|e| e.ok())
224 .filter(|e| {
225 let path = e.path();
226 if let Some(ext) = path.extension() {
227 extensions.contains(&ext.to_string_lossy().to_lowercase().as_str())
228 } else {
229 false
230 }
231 })
232 .collect();
233
234 entries.sort_by_key(|e| e.path());
235 entries.first().map(|e| e.path().to_string_lossy().to_string())
236 }
237 ```
238
239 ### 4.4 Shared Visual Settings
240
241 ```rust
242 // gardm-greeter/src/config.rs
243
244 use serde::Deserialize;
245
246 #[derive(Debug, Deserialize)]
247 pub struct GreeterConfig {
248 #[serde(default)]
249 pub visual: VisualConfig,
250 #[serde(default)]
251 pub garbg: GarbgIntegration,
252 }
253
254 #[derive(Debug, Deserialize, Default)]
255 pub struct VisualConfig {
256 /// Blur radius for background (should match gar lock screen)
257 #[serde(default = "default_blur_radius")]
258 pub blur_radius: f32,
259
260 /// Background brightness (0.0-1.0, lower = darker)
261 #[serde(default = "default_brightness")]
262 pub blur_brightness: f32,
263
264 /// Corner radius for UI elements (match gar's corner_radius)
265 #[serde(default = "default_corner_radius")]
266 pub corner_radius: f64,
267 }
268
269 #[derive(Debug, Deserialize, Default)]
270 pub struct GarbgIntegration {
271 /// Whether to use garbg wallpaper
272 #[serde(default = "default_true")]
273 pub enabled: bool,
274
275 /// Fallback wallpaper if garbg not configured
276 #[serde(default = "default_fallback")]
277 pub fallback: String,
278 }
279
280 fn default_blur_radius() -> f32 { 20.0 }
281 fn default_brightness() -> f32 { 0.7 }
282 fn default_corner_radius() -> f64 { 18.0 }
283 fn default_true() -> bool { true }
284 fn default_fallback() -> String { "/usr/share/gardm/backgrounds/default.jpg".to_string() }
285 ```
286
287 ### 4.5 Integration with Greeter Main Loop
288
289 ```rust
290 // gardm-greeter/src/main.rs (updated)
291
292 fn main() -> anyhow::Result<()> {
293 let config = GreeterConfig::load()?;
294 let window = GreeterWindow::new()?;
295
296 // Resolve wallpaper using garbg integration
297 let wallpaper_path = if config.garbg.enabled {
298 let resolver = WallpaperResolver::new(&config.garbg.fallback);
299 // Initially no username known, use system defaults
300 resolver.resolve(None)
301 } else {
302 config.garbg.fallback.clone()
303 };
304
305 // Load and blur background
306 let background = load_blurred_background(
307 &wallpaper_path,
308 window.width() as u32,
309 window.height() as u32,
310 config.visual.blur_radius,
311 config.visual.blur_brightness,
312 )?;
313
314 let mut form = LoginForm::new(
315 window.width() as f64,
316 window.height() as f64,
317 config.visual.corner_radius,
318 );
319
320 // When username is entered, potentially update wallpaper
321 // to user-specific one (optional enhancement)
322
323 // ... rest of event loop ...
324 }
325 ```
326
327 ### 4.6 Session Transition Effect
328
329 ```rust
330 // gardm-greeter/src/transition.rs
331
332 use std::time::{Duration, Instant};
333
334 /// Fade out transition before starting session
335 pub struct FadeOutTransition {
336 start_time: Instant,
337 duration: Duration,
338 }
339
340 impl FadeOutTransition {
341 pub fn new(duration_ms: u64) -> Self {
342 Self {
343 start_time: Instant::now(),
344 duration: Duration::from_millis(duration_ms),
345 }
346 }
347
348 /// Get current opacity (1.0 -> 0.0)
349 pub fn opacity(&self) -> f64 {
350 let elapsed = self.start_time.elapsed();
351 if elapsed >= self.duration {
352 0.0
353 } else {
354 1.0 - (elapsed.as_secs_f64() / self.duration.as_secs_f64())
355 }
356 }
357
358 pub fn is_complete(&self) -> bool {
359 self.start_time.elapsed() >= self.duration
360 }
361 }
362
363 /// Apply fade during session start
364 pub fn render_with_fade(
365 ctx: &cairo::Context,
366 background: &image::RgbaImage,
367 form: &LoginForm,
368 fade: Option<&FadeOutTransition>,
369 ) -> anyhow::Result<()> {
370 // Render background (always full opacity)
371 render_background(ctx, background)?;
372
373 // Render UI with fade
374 if let Some(fade) = fade {
375 ctx.push_group();
376 form.render(ctx, &pango_ctx)?;
377 ctx.pop_group_to_source()?;
378 ctx.paint_with_alpha(fade.opacity())?;
379 } else {
380 form.render(ctx, &pango_ctx)?;
381 }
382
383 Ok(())
384 }
385 ```
386
387 ## Acceptance Criteria
388
389 1. Greeter shows same wallpaper as garbg will after login
390 2. Blur settings are consistent with gar lock screen
391 3. Fallback works when garbg is not configured
392 4. User-specific wallpaper is used when username is known
393 5. Smooth fade transition when starting session
394 6. No flash of different wallpaper during login
395
396 ## Pitfalls to Avoid
397
398 1. **File permissions** - greeter runs as gardm user, may not access user home
399 2. **Race conditions** - garbg state file might be stale
400 3. **Directory vs file** - garbg source might be a directory
401 4. **Missing files** - wallpaper might have been deleted
402 5. **Large images** - blur on 4K images is slow, cache if needed
403
404 ## Testing
405
406 ```bash
407 # Test wallpaper resolution
408 # 1. With garbg configured and running
409 garbg set ~/Pictures/wallpapers --random
410 DISPLAY=:1 ./target/release/gardm-greeter
411
412 # 2. With garbg config but no playlist
413 rm ~/.local/state/garbg-state.json
414 DISPLAY=:1 ./target/release/gardm-greeter
415
416 # 3. Without any garbg config (should use fallback)
417 mv ~/.config/garbg/config.toml ~/.config/garbg/config.toml.bak
418 DISPLAY=:1 ./target/release/gardm-greeter
419 ```
420
421 ## Dependencies for This Sprint
422
423 ```toml
424 # gardm-greeter/Cargo.toml
425 [dependencies]
426 shellexpand = "3.0"
427 toml = "0.8"
428 serde_json = "1.0"
429 ```
430
431 ## Integration Checklist
432
433 - [ ] Greeter reads `~/.config/garbg/config.toml`
434 - [ ] Greeter reads `$XDG_RUNTIME_DIR/garbg-state.json`
435 - [ ] Blur radius matches gar lock screen (if implemented)
436 - [ ] Corner radius matches gar's `corner_radius` setting
437 - [ ] Fallback image is bundled with gardm package
438 - [ ] Fade transition timing feels smooth (150-200ms)
439
440 ## Future Enhancements
441
442 - [ ] Per-user wallpaper preview before login
443 - [ ] Animated wallpaper support (match garbg animation)
444 - [ ] Workspace-specific wallpaper from garbg config
445 - [ ] Live wallpaper update if garbg changes during greeter
446
447 ## Next Steps
448
449 After Sprint 4, consider:
450 - Sprint 5: Power buttons and session selector
451 - Sprint 6: User list with avatars
452 - Sprint 7: Accessibility and theming
453 - Sprint 8: Multi-monitor support