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
- Greeter shows same wallpaper as garbg will after login
- Blur settings are consistent with gar lock screen
- Fallback works when garbg is not configured
- User-specific wallpaper is used when username is known
- Smooth fade transition when starting session
- No flash of different wallpaper during login
Pitfalls to Avoid
- File permissions - greeter runs as gardm user, may not access user home
- Race conditions - garbg state file might be stale
- Directory vs file - garbg source might be a directory
- Missing files - wallpaper might have been deleted
- 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_radiussetting - 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 |