gardesk/gardm / 839cdcb

Browse files

theme and accessibility support

Add theming system with customizable colors, fonts, and accessibility
options for the greeter UI.

Features:
- theme.rs with Color parsing (hex #RRGGBB, rgba(), rgb())
- Customizable colors: panel, text, accent, input, button
- Font family and size configuration
- High contrast mode with enhanced visibility
- Large text accessibility option (25% larger)
- Reduce motion option (disables fade transitions)
- All widgets updated to use theme values
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
839cdcbbc07c51622a0d602b3b8d64d5cee86b41
Parents
83a28a8
Tree
e2c50e7

6 changed files

StatusFile+-
M gardm-greeter/src/config.rs 20 1
M gardm-greeter/src/main.rs 14 4
A gardm-greeter/src/theme.rs 367 0
M gardm-greeter/src/widgets/login_form.rs 47 32
M gardm-greeter/src/widgets/session_selector.rs 21 14
M gardm-greeter/src/widgets/user_list.rs 9 6
gardm-greeter/src/config.rsmodified
@@ -1,7 +1,8 @@
11
 //! Greeter configuration
22
 //!
3
-//! Loads visual settings and garbg integration options.
3
+//! Loads visual settings, theming, and accessibility options.
44
 
5
+use crate::theme::{AccessibilityConfig, Theme, ThemeConfig};
56
 use anyhow::{Context, Result};
67
 use serde::Deserialize;
78
 use std::path::PathBuf;
@@ -13,6 +14,10 @@ pub struct GreeterConfig {
1314
     pub visual: VisualConfig,
1415
     #[serde(default)]
1516
     pub garbg: GarbgIntegration,
17
+    #[serde(default)]
18
+    pub theme: ThemeConfig,
19
+    #[serde(default)]
20
+    pub accessibility: AccessibilityConfig,
1621
 }
1722
 
1823
 /// Visual appearance settings
@@ -110,4 +115,18 @@ impl GreeterConfig {
110115
         tracing::debug!("No config file found, using defaults");
111116
         Ok(Self::default())
112117
     }
118
+
119
+    /// Build the theme from config, applying accessibility options
120
+    pub fn build_theme(&self) -> Theme {
121
+        self.theme.clone().into_theme(&self.accessibility)
122
+    }
123
+
124
+    /// Get effective fade duration (0 if reduce_motion is enabled)
125
+    pub fn effective_fade_duration(&self) -> u64 {
126
+        if self.accessibility.reduce_motion {
127
+            0
128
+        } else {
129
+            self.visual.fade_duration_ms
130
+        }
131
+    }
113132
 }
gardm-greeter/src/main.rsmodified
@@ -10,6 +10,7 @@ mod icons;
1010
 mod keyboard;
1111
 mod monitors;
1212
 mod render;
13
+mod theme;
1314
 mod transition;
1415
 mod widgets;
1516
 mod window;
@@ -49,6 +50,15 @@ async fn main() -> Result<()> {
4950
     let config = GreeterConfig::load().unwrap_or_default();
5051
     tracing::debug!(?config, "Greeter configuration");
5152
 
53
+    // Build theme from config with accessibility options
54
+    let theme = config.build_theme();
55
+    tracing::debug!(
56
+        high_contrast = config.accessibility.high_contrast,
57
+        large_text = config.accessibility.large_text,
58
+        reduce_motion = config.accessibility.reduce_motion,
59
+        "Theme built"
60
+    );
61
+
5262
     // Create X11 window
5363
     let window = GreeterWindow::new().context("Failed to create window")?;
5464
     let width = window.width();
@@ -197,13 +207,13 @@ async fn main() -> Result<()> {
197207
 
198208
             render_with_fade(&ctx, opacity, |ctx| {
199209
                 // User list (above login form)
200
-                user_list.render(ctx, &pango_ctx)?;
210
+                user_list.render(ctx, &pango_ctx, &theme)?;
201211
 
202212
                 // Login form
203
-                form.render(ctx, &pango_ctx)?;
213
+                form.render(ctx, &pango_ctx, &theme)?;
204214
 
205215
                 // Session selector
206
-                session_selector.render(ctx, &pango_ctx)?;
216
+                session_selector.render(ctx, &pango_ctx, &theme)?;
207217
 
208218
                 // Power buttons
209219
                 power_buttons.render(ctx)?;
@@ -297,7 +307,7 @@ async fn main() -> Result<()> {
297307
                                     &mut client,
298308
                                     &mut form,
299309
                                     &session_exec,
300
-                                    config.visual.fade_duration_ms,
310
+                                    config.effective_fade_duration(),
301311
                                 )
302312
                                 .await?
303313
                                 {
gardm-greeter/src/theme.rsadded
@@ -0,0 +1,367 @@
1
+//! Theme and accessibility configuration
2
+//!
3
+//! Supports color customization and accessibility options.
4
+
5
+use serde::Deserialize;
6
+
7
+/// RGBA color (0.0-1.0 range)
8
+#[derive(Debug, Clone, Copy)]
9
+pub struct Color {
10
+    pub r: f64,
11
+    pub g: f64,
12
+    pub b: f64,
13
+    pub a: f64,
14
+}
15
+
16
+impl Color {
17
+    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
18
+        Self { r, g, b, a: 1.0 }
19
+    }
20
+
21
+    pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
22
+        Self { r, g, b, a }
23
+    }
24
+
25
+    /// Parse color from hex (#RRGGBB or #RRGGBBAA) or rgba(r, g, b, a) format
26
+    pub fn parse(s: &str) -> Option<Self> {
27
+        let s = s.trim();
28
+
29
+        // Try hex format
30
+        if s.starts_with('#') {
31
+            return Self::parse_hex(s);
32
+        }
33
+
34
+        // Try rgba() format
35
+        if s.starts_with("rgba(") && s.ends_with(')') {
36
+            return Self::parse_rgba(s);
37
+        }
38
+
39
+        // Try rgb() format
40
+        if s.starts_with("rgb(") && s.ends_with(')') {
41
+            return Self::parse_rgb(s);
42
+        }
43
+
44
+        None
45
+    }
46
+
47
+    fn parse_hex(s: &str) -> Option<Self> {
48
+        let s = s.trim_start_matches('#');
49
+
50
+        match s.len() {
51
+            6 => {
52
+                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
53
+                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
54
+                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
55
+                Some(Self::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0))
56
+            }
57
+            8 => {
58
+                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
59
+                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
60
+                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
61
+                let a = u8::from_str_radix(&s[6..8], 16).ok()?;
62
+                Some(Self::rgba(
63
+                    r as f64 / 255.0,
64
+                    g as f64 / 255.0,
65
+                    b as f64 / 255.0,
66
+                    a as f64 / 255.0,
67
+                ))
68
+            }
69
+            _ => None,
70
+        }
71
+    }
72
+
73
+    fn parse_rgba(s: &str) -> Option<Self> {
74
+        let inner = s.trim_start_matches("rgba(").trim_end_matches(')');
75
+        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
76
+        if parts.len() != 4 {
77
+            return None;
78
+        }
79
+
80
+        let r: f64 = parts[0].parse().ok()?;
81
+        let g: f64 = parts[1].parse().ok()?;
82
+        let b: f64 = parts[2].parse().ok()?;
83
+        let a: f64 = parts[3].parse().ok()?;
84
+
85
+        // Support both 0-255 and 0-1 ranges for rgb components
86
+        let (r, g, b) = if r > 1.0 || g > 1.0 || b > 1.0 {
87
+            (r / 255.0, g / 255.0, b / 255.0)
88
+        } else {
89
+            (r, g, b)
90
+        };
91
+
92
+        Some(Self::rgba(r, g, b, a))
93
+    }
94
+
95
+    fn parse_rgb(s: &str) -> Option<Self> {
96
+        let inner = s.trim_start_matches("rgb(").trim_end_matches(')');
97
+        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
98
+        if parts.len() != 3 {
99
+            return None;
100
+        }
101
+
102
+        let r: f64 = parts[0].parse().ok()?;
103
+        let g: f64 = parts[1].parse().ok()?;
104
+        let b: f64 = parts[2].parse().ok()?;
105
+
106
+        // Support both 0-255 and 0-1 ranges
107
+        let (r, g, b) = if r > 1.0 || g > 1.0 || b > 1.0 {
108
+            (r / 255.0, g / 255.0, b / 255.0)
109
+        } else {
110
+            (r, g, b)
111
+        };
112
+
113
+        Some(Self::rgb(r, g, b))
114
+    }
115
+}
116
+
117
+impl Default for Color {
118
+    fn default() -> Self {
119
+        Self::rgb(1.0, 1.0, 1.0)
120
+    }
121
+}
122
+
123
+/// Theme configuration for visual customization
124
+#[derive(Debug, Clone)]
125
+pub struct Theme {
126
+    // Panel/background colors
127
+    pub panel_background: Color,
128
+    pub background_overlay: Color,
129
+
130
+    // Text colors
131
+    pub text_primary: Color,
132
+    pub text_secondary: Color,
133
+    pub text_error: Color,
134
+    pub text_info: Color,
135
+
136
+    // Accent colors
137
+    pub accent: Color,
138
+    pub accent_hover: Color,
139
+
140
+    // Input field colors
141
+    pub input_background: Color,
142
+    pub input_background_focused: Color,
143
+    pub input_border: Color,
144
+
145
+    // Button colors
146
+    pub button_background: Color,
147
+    pub button_background_disabled: Color,
148
+
149
+    // Typography
150
+    pub font_family: String,
151
+    pub font_size_normal: i32,
152
+    pub font_size_large: i32,
153
+    pub font_size_title: i32,
154
+
155
+    // Layout
156
+    pub corner_radius: f64,
157
+}
158
+
159
+impl Default for Theme {
160
+    fn default() -> Self {
161
+        Self {
162
+            panel_background: Color::rgba(0.1, 0.1, 0.1, 0.85),
163
+            background_overlay: Color::rgba(0.0, 0.0, 0.0, 0.0),
164
+
165
+            text_primary: Color::rgb(1.0, 1.0, 1.0),
166
+            text_secondary: Color::rgba(0.8, 0.8, 0.8, 1.0),
167
+            text_error: Color::rgb(1.0, 0.3, 0.3),
168
+            text_info: Color::rgba(0.7, 0.7, 0.7, 1.0),
169
+
170
+            accent: Color::rgb(0.2, 0.5, 0.8),
171
+            accent_hover: Color::rgb(0.3, 0.6, 0.9),
172
+
173
+            input_background: Color::rgba(0.25, 0.25, 0.25, 1.0),
174
+            input_background_focused: Color::rgba(0.2, 0.4, 0.6, 1.0),
175
+            input_border: Color::rgb(0.3, 0.6, 0.9),
176
+
177
+            button_background: Color::rgba(0.2, 0.5, 0.8, 1.0),
178
+            button_background_disabled: Color::rgba(0.3, 0.3, 0.3, 1.0),
179
+
180
+            font_family: "Sans".to_string(),
181
+            font_size_normal: 14,
182
+            font_size_large: 18,
183
+            font_size_title: 24,
184
+
185
+            corner_radius: 16.0,
186
+        }
187
+    }
188
+}
189
+
190
+impl Theme {
191
+    /// Create a high-contrast theme variant
192
+    pub fn high_contrast() -> Self {
193
+        Self {
194
+            panel_background: Color::rgba(0.0, 0.0, 0.0, 0.95),
195
+            background_overlay: Color::rgba(0.0, 0.0, 0.0, 0.5),
196
+
197
+            text_primary: Color::rgb(1.0, 1.0, 1.0),
198
+            text_secondary: Color::rgb(1.0, 1.0, 0.0), // Yellow for visibility
199
+            text_error: Color::rgb(1.0, 0.2, 0.2),
200
+            text_info: Color::rgb(0.2, 1.0, 0.2),
201
+
202
+            accent: Color::rgb(0.0, 0.8, 1.0),      // Bright cyan
203
+            accent_hover: Color::rgb(0.2, 1.0, 1.0),
204
+
205
+            input_background: Color::rgba(0.0, 0.0, 0.0, 1.0),
206
+            input_background_focused: Color::rgba(0.0, 0.2, 0.4, 1.0),
207
+            input_border: Color::rgb(1.0, 1.0, 0.0), // Yellow border
208
+
209
+            button_background: Color::rgba(0.0, 0.6, 0.8, 1.0),
210
+            button_background_disabled: Color::rgba(0.3, 0.3, 0.3, 1.0),
211
+
212
+            font_family: "Sans".to_string(),
213
+            font_size_normal: 16, // Slightly larger
214
+            font_size_large: 20,
215
+            font_size_title: 28,
216
+
217
+            corner_radius: 8.0,
218
+        }
219
+    }
220
+
221
+    /// Apply large text accessibility option
222
+    pub fn with_large_text(mut self) -> Self {
223
+        self.font_size_normal = (self.font_size_normal as f64 * 1.25) as i32;
224
+        self.font_size_large = (self.font_size_large as f64 * 1.25) as i32;
225
+        self.font_size_title = (self.font_size_title as f64 * 1.25) as i32;
226
+        self
227
+    }
228
+}
229
+
230
+/// Accessibility configuration
231
+#[derive(Debug, Clone, Deserialize)]
232
+pub struct AccessibilityConfig {
233
+    /// Enable high contrast mode
234
+    #[serde(default)]
235
+    pub high_contrast: bool,
236
+
237
+    /// Enable larger text
238
+    #[serde(default)]
239
+    pub large_text: bool,
240
+
241
+    /// Disable fade transitions
242
+    #[serde(default)]
243
+    pub reduce_motion: bool,
244
+}
245
+
246
+impl Default for AccessibilityConfig {
247
+    fn default() -> Self {
248
+        Self {
249
+            high_contrast: false,
250
+            large_text: false,
251
+            reduce_motion: false,
252
+        }
253
+    }
254
+}
255
+
256
+/// Raw theme config for deserialization
257
+#[derive(Debug, Clone, Deserialize, Default)]
258
+pub struct ThemeConfig {
259
+    #[serde(default)]
260
+    pub panel_background: Option<String>,
261
+    #[serde(default)]
262
+    pub background_overlay: Option<String>,
263
+    #[serde(default)]
264
+    pub text_primary: Option<String>,
265
+    #[serde(default)]
266
+    pub text_secondary: Option<String>,
267
+    #[serde(default)]
268
+    pub text_error: Option<String>,
269
+    #[serde(default)]
270
+    pub text_info: Option<String>,
271
+    #[serde(default)]
272
+    pub accent: Option<String>,
273
+    #[serde(default)]
274
+    pub font_family: Option<String>,
275
+    #[serde(default)]
276
+    pub font_size_normal: Option<i32>,
277
+    #[serde(default)]
278
+    pub font_size_large: Option<i32>,
279
+    #[serde(default)]
280
+    pub font_size_title: Option<i32>,
281
+    #[serde(default)]
282
+    pub corner_radius: Option<f64>,
283
+}
284
+
285
+impl ThemeConfig {
286
+    /// Convert to Theme, applying parsed colors over defaults
287
+    pub fn into_theme(self, accessibility: &AccessibilityConfig) -> Theme {
288
+        let mut base = if accessibility.high_contrast {
289
+            Theme::high_contrast()
290
+        } else {
291
+            Theme::default()
292
+        };
293
+
294
+        // Apply custom colors
295
+        if let Some(ref s) = self.panel_background {
296
+            if let Some(c) = Color::parse(s) {
297
+                base.panel_background = c;
298
+            }
299
+        }
300
+        if let Some(ref s) = self.background_overlay {
301
+            if let Some(c) = Color::parse(s) {
302
+                base.background_overlay = c;
303
+            }
304
+        }
305
+        if let Some(ref s) = self.text_primary {
306
+            if let Some(c) = Color::parse(s) {
307
+                base.text_primary = c;
308
+            }
309
+        }
310
+        if let Some(ref s) = self.text_secondary {
311
+            if let Some(c) = Color::parse(s) {
312
+                base.text_secondary = c;
313
+            }
314
+        }
315
+        if let Some(ref s) = self.text_error {
316
+            if let Some(c) = Color::parse(s) {
317
+                base.text_error = c;
318
+            }
319
+        }
320
+        if let Some(ref s) = self.text_info {
321
+            if let Some(c) = Color::parse(s) {
322
+                base.text_info = c;
323
+            }
324
+        }
325
+        if let Some(ref s) = self.accent {
326
+            if let Some(c) = Color::parse(s) {
327
+                base.accent = c;
328
+                // Derive hover color (slightly brighter)
329
+                base.accent_hover = Color::rgba(
330
+                    (c.r + 0.1).min(1.0),
331
+                    (c.g + 0.1).min(1.0),
332
+                    (c.b + 0.1).min(1.0),
333
+                    c.a,
334
+                );
335
+                base.input_background_focused = Color::rgba(c.r * 0.5, c.g * 0.5, c.b * 0.5, 1.0);
336
+                base.input_border = c;
337
+                base.button_background = c;
338
+            }
339
+        }
340
+
341
+        // Typography
342
+        if let Some(font) = self.font_family {
343
+            base.font_family = font;
344
+        }
345
+        if let Some(size) = self.font_size_normal {
346
+            base.font_size_normal = size;
347
+        }
348
+        if let Some(size) = self.font_size_large {
349
+            base.font_size_large = size;
350
+        }
351
+        if let Some(size) = self.font_size_title {
352
+            base.font_size_title = size;
353
+        }
354
+
355
+        // Layout
356
+        if let Some(radius) = self.corner_radius {
357
+            base.corner_radius = radius;
358
+        }
359
+
360
+        // Apply accessibility modifiers
361
+        if accessibility.large_text {
362
+            base = base.with_large_text();
363
+        }
364
+
365
+        base
366
+    }
367
+}
gardm-greeter/src/widgets/login_form.rsmodified
@@ -3,6 +3,7 @@
33
 //! Renders username/password fields, login button, and handles keyboard input.
44
 
55
 use crate::render::rounded_rectangle;
6
+use crate::theme::Theme;
67
 use anyhow::Result;
78
 use cairo::Context;
89
 use pango::{FontDescription, Layout, Weight};
@@ -53,19 +54,21 @@ impl LoginForm {
5354
     }
5455
 
5556
     /// Render the login form
56
-    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
57
-        // Background panel (semi-transparent dark)
58
-        ctx.set_source_rgba(0.1, 0.1, 0.1, 0.85);
59
-        rounded_rectangle(ctx, self.x, self.y, self.width, self.height, 16.0);
57
+    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
58
+        // Background panel
59
+        let bg = &theme.panel_background;
60
+        ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
61
+        rounded_rectangle(ctx, self.x, self.y, self.width, self.height, theme.corner_radius);
6062
         ctx.fill()?;
6163
 
6264
         // Title
63
-        self.render_title(ctx, pango_ctx)?;
65
+        self.render_title(ctx, pango_ctx, theme)?;
6466
 
6567
         // Username field
6668
         self.render_input_field(
6769
             ctx,
6870
             pango_ctx,
71
+            theme,
6972
             "Username",
7073
             &self.username,
7174
             self.y + 100.0,
@@ -77,6 +80,7 @@ impl LoginForm {
7780
         self.render_input_field(
7881
             ctx,
7982
             pango_ctx,
83
+            theme,
8084
             "Password",
8185
             &masked_password,
8286
             self.y + 170.0,
@@ -85,29 +89,32 @@ impl LoginForm {
8589
 
8690
         // Error message
8791
         if let Some(ref msg) = self.error_message {
88
-            self.render_message(ctx, pango_ctx, msg, (1.0, 0.3, 0.3))?;
92
+            let c = &theme.text_error;
93
+            self.render_message(ctx, pango_ctx, theme, msg, (c.r, c.g, c.b))?;
8994
         } else if let Some(ref msg) = self.info_message {
90
-            self.render_message(ctx, pango_ctx, msg, (0.7, 0.7, 0.7))?;
95
+            let c = &theme.text_info;
96
+            self.render_message(ctx, pango_ctx, theme, msg, (c.r, c.g, c.b))?;
9197
         }
9298
 
9399
         // Login button
94
-        self.render_button(ctx, pango_ctx)?;
100
+        self.render_button(ctx, pango_ctx, theme)?;
95101
 
96102
         Ok(())
97103
     }
98104
 
99
-    fn render_title(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
105
+    fn render_title(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
100106
         let layout = Layout::new(pango_ctx);
101107
         let mut font = FontDescription::new();
102
-        font.set_family("Sans");
103
-        font.set_size(24 * pango::SCALE);
108
+        font.set_family(&theme.font_family);
109
+        font.set_size(theme.font_size_title * pango::SCALE);
104110
         font.set_weight(Weight::Bold);
105111
         layout.set_font_description(Some(&font));
106112
         layout.set_text("Welcome");
107113
 
108114
         let (text_width, _) = layout.pixel_size();
109115
 
110
-        ctx.set_source_rgb(1.0, 1.0, 1.0);
116
+        let c = &theme.text_primary;
117
+        ctx.set_source_rgb(c.r, c.g, c.b);
111118
         ctx.move_to(
112119
             self.x + (self.width - text_width as f64) / 2.0,
113120
             self.y + 30.0,
@@ -121,6 +128,7 @@ impl LoginForm {
121128
         &self,
122129
         ctx: &Context,
123130
         pango_ctx: &pango::Context,
131
+        theme: &Theme,
124132
         label: &str,
125133
         value: &str,
126134
         y: f64,
@@ -132,37 +140,41 @@ impl LoginForm {
132140
 
133141
         // Label
134142
         let mut font = FontDescription::new();
135
-        font.set_family("Sans");
143
+        font.set_family(&theme.font_family);
136144
         font.set_size(11 * pango::SCALE);
137145
 
138146
         let label_layout = Layout::new(pango_ctx);
139147
         label_layout.set_font_description(Some(&font));
140148
         label_layout.set_text(label);
141149
 
142
-        ctx.set_source_rgba(0.8, 0.8, 0.8, 1.0);
150
+        let c = &theme.text_secondary;
151
+        ctx.set_source_rgba(c.r, c.g, c.b, c.a);
143152
         ctx.move_to(field_x, y - 18.0);
144153
         pangocairo::functions::show_layout(ctx, &label_layout);
145154
 
146155
         // Input box background
147
-        if focused {
148
-            ctx.set_source_rgba(0.2, 0.4, 0.6, 1.0);
156
+        let bg = if focused {
157
+            &theme.input_background_focused
149158
         } else {
150
-            ctx.set_source_rgba(0.25, 0.25, 0.25, 1.0);
151
-        }
159
+            &theme.input_background
160
+        };
161
+        ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
152162
         rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
153163
         ctx.fill()?;
154164
 
155
-        // Input box border
165
+        // Input box border (focused only)
156166
         if focused {
157
-            ctx.set_source_rgba(0.3, 0.6, 0.9, 1.0);
167
+            let bc = &theme.input_border;
168
+            ctx.set_source_rgba(bc.r, bc.g, bc.b, bc.a);
158169
             rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
159170
             ctx.set_line_width(2.0);
160171
             ctx.stroke()?;
161172
         }
162173
 
163174
         // Text value
164
-        ctx.set_source_rgb(1.0, 1.0, 1.0);
165
-        font.set_size(14 * pango::SCALE);
175
+        let tc = &theme.text_primary;
176
+        ctx.set_source_rgb(tc.r, tc.g, tc.b);
177
+        font.set_size(theme.font_size_normal * pango::SCALE);
166178
         let value_layout = Layout::new(pango_ctx);
167179
         value_layout.set_font_description(Some(&font));
168180
         value_layout.set_text(if value.is_empty() { " " } else { value });
@@ -177,7 +189,7 @@ impl LoginForm {
177189
             } else {
178190
                 field_x + 12.0 + text_width as f64
179191
             };
180
-            ctx.set_source_rgb(1.0, 1.0, 1.0);
192
+            ctx.set_source_rgb(tc.r, tc.g, tc.b);
181193
             ctx.rectangle(cursor_x, y + 8.0, 2.0, 24.0);
182194
             ctx.fill()?;
183195
         }
@@ -189,13 +201,14 @@ impl LoginForm {
189201
         &self,
190202
         ctx: &Context,
191203
         pango_ctx: &pango::Context,
204
+        theme: &Theme,
192205
         msg: &str,
193206
         color: (f64, f64, f64),
194207
     ) -> Result<()> {
195208
         ctx.set_source_rgb(color.0, color.1, color.2);
196209
 
197210
         let mut font = FontDescription::new();
198
-        font.set_family("Sans");
211
+        font.set_family(&theme.font_family);
199212
         font.set_size(12 * pango::SCALE);
200213
 
201214
         let layout = Layout::new(pango_ctx);
@@ -209,26 +222,28 @@ impl LoginForm {
209222
         Ok(())
210223
     }
211224
 
212
-    fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
225
+    fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
213226
         let btn_width = 120.0;
214227
         let btn_height = 36.0;
215228
         let btn_x = self.x + (self.width - btn_width) / 2.0;
216229
         let btn_y = self.y + 275.0;
217230
 
218231
         // Button background
219
-        if self.is_loading {
220
-            ctx.set_source_rgba(0.3, 0.3, 0.3, 1.0);
232
+        let bg = if self.is_loading {
233
+            &theme.button_background_disabled
221234
         } else {
222
-            ctx.set_source_rgba(0.2, 0.5, 0.8, 1.0);
223
-        }
235
+            &theme.button_background
236
+        };
237
+        ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
224238
         rounded_rectangle(ctx, btn_x, btn_y, btn_width, btn_height, 8.0);
225239
         ctx.fill()?;
226240
 
227241
         // Button text
228
-        ctx.set_source_rgb(1.0, 1.0, 1.0);
242
+        let tc = &theme.text_primary;
243
+        ctx.set_source_rgb(tc.r, tc.g, tc.b);
229244
         let mut font = FontDescription::new();
230
-        font.set_family("Sans");
231
-        font.set_size(14 * pango::SCALE);
245
+        font.set_family(&theme.font_family);
246
+        font.set_size(theme.font_size_normal * pango::SCALE);
232247
         font.set_weight(Weight::Bold);
233248
 
234249
         let layout = Layout::new(pango_ctx);
gardm-greeter/src/widgets/session_selector.rsmodified
@@ -4,6 +4,7 @@
44
 
55
 use crate::icons;
66
 use crate::render::rounded_rectangle;
7
+use crate::theme::Theme;
78
 use anyhow::Result;
89
 use cairo::Context;
910
 use gardm_ipc::SessionInfo;
@@ -68,21 +69,22 @@ impl SessionSelector {
6869
     }
6970
 
7071
     /// Render the session selector
71
-    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
72
+    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
7273
         // Main button (always visible)
73
-        self.render_button(ctx, pango_ctx)?;
74
+        self.render_button(ctx, pango_ctx, theme)?;
7475
 
7576
         // Dropdown list (when expanded)
7677
         if self.expanded && !self.sessions.is_empty() {
77
-            self.render_dropdown(ctx, pango_ctx)?;
78
+            self.render_dropdown(ctx, pango_ctx, theme)?;
7879
         }
7980
 
8081
         Ok(())
8182
     }
8283
 
83
-    fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
84
+    fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
8485
         // Background
85
-        ctx.set_source_rgba(0.2, 0.2, 0.2, 0.9);
86
+        let bg = &theme.input_background;
87
+        ctx.set_source_rgba(bg.r, bg.g, bg.b, 0.9);
8688
         rounded_rectangle(ctx, self.x, self.y, self.width, self.item_height, 8.0);
8789
         ctx.fill()?;
8890
 
@@ -99,14 +101,15 @@ impl SessionSelector {
99101
             .unwrap_or("No sessions");
100102
 
101103
         let mut font = FontDescription::new();
102
-        font.set_family("Sans");
104
+        font.set_family(&theme.font_family);
103105
         font.set_size(13 * pango::SCALE);
104106
 
105107
         let layout = Layout::new(pango_ctx);
106108
         layout.set_font_description(Some(&font));
107109
         layout.set_text(name);
108110
 
109
-        ctx.set_source_rgb(1.0, 1.0, 1.0);
111
+        let tc = &theme.text_primary;
112
+        ctx.set_source_rgb(tc.r, tc.g, tc.b);
110113
         ctx.move_to(self.x + 12.0, self.y + (self.item_height - 16.0) / 2.0);
111114
         pangocairo::functions::show_layout(ctx, &layout);
112115
 
@@ -114,18 +117,20 @@ impl SessionSelector {
114117
         let chevron_size = 16.0;
115118
         let chevron_x = self.x + self.width - chevron_size - 12.0;
116119
         let chevron_y = self.y + (self.item_height - chevron_size) / 2.0;
117
-        ctx.set_source_rgba(0.7, 0.7, 0.7, 1.0);
120
+        let sc = &theme.text_secondary;
121
+        ctx.set_source_rgba(sc.r, sc.g, sc.b, sc.a);
118122
         icons::draw_chevron_down(ctx, chevron_x, chevron_y, chevron_size);
119123
 
120124
         Ok(())
121125
     }
122126
 
123
-    fn render_dropdown(&self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
127
+    fn render_dropdown(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
124128
         let dropdown_height = self.sessions.len() as f64 * self.item_height;
125129
         let dropdown_y = self.y - dropdown_height - 4.0; // Above the button
126130
 
127131
         // Dropdown background
128
-        ctx.set_source_rgba(0.15, 0.15, 0.15, 0.95);
132
+        let bg = &theme.panel_background;
133
+        ctx.set_source_rgba(bg.r * 0.8, bg.g * 0.8, bg.b * 0.8, 0.95);
129134
         rounded_rectangle(ctx, self.x, dropdown_y, self.width, dropdown_height, 8.0);
130135
         ctx.fill()?;
131136
 
@@ -137,7 +142,7 @@ impl SessionSelector {
137142
 
138143
         // Items
139144
         let mut font = FontDescription::new();
140
-        font.set_family("Sans");
145
+        font.set_family(&theme.font_family);
141146
         font.set_size(13 * pango::SCALE);
142147
 
143148
         for (i, session) in self.sessions.iter().enumerate() {
@@ -147,7 +152,8 @@ impl SessionSelector {
147152
 
148153
             // Item background on hover
149154
             if is_hovered {
150
-                ctx.set_source_rgba(0.3, 0.5, 0.8, 0.5);
155
+                let ac = &theme.accent;
156
+                ctx.set_source_rgba(ac.r, ac.g, ac.b, 0.5);
151157
                 if i == 0 {
152158
                     // First item - round top corners
153159
                     rounded_rectangle(ctx, self.x + 2.0, item_y + 2.0, self.width - 4.0, self.item_height - 2.0, 6.0);
@@ -171,10 +177,11 @@ impl SessionSelector {
171177
             layout.set_font_description(Some(&font));
172178
             layout.set_text(&session.name);
173179
 
180
+            let tc = &theme.text_primary;
174181
             if is_selected {
175
-                ctx.set_source_rgb(1.0, 1.0, 1.0);
182
+                ctx.set_source_rgb(tc.r, tc.g, tc.b);
176183
             } else {
177
-                ctx.set_source_rgba(0.9, 0.9, 0.9, 1.0);
184
+                ctx.set_source_rgba(tc.r, tc.g, tc.b, 0.9);
178185
             }
179186
             ctx.move_to(self.x + 36.0, item_y + (self.item_height - 16.0) / 2.0);
180187
             pangocairo::functions::show_layout(ctx, &layout);
gardm-greeter/src/widgets/user_list.rsmodified
@@ -7,6 +7,7 @@ use crate::avatar::{
77
     string_to_hue, AvatarCache,
88
 };
99
 use crate::render::rounded_rectangle;
10
+use crate::theme::Theme;
1011
 use anyhow::Result;
1112
 use cairo::Context;
1213
 use gardm_ipc::UserInfo;
@@ -79,7 +80,7 @@ impl UserList {
7980
     }
8081
 
8182
     /// Render the user list
82
-    pub fn render(&mut self, ctx: &Context, pango_ctx: &pango::Context) -> Result<()> {
83
+    pub fn render(&mut self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
8384
         if self.users.is_empty() {
8485
             return Ok(());
8586
         }
@@ -100,11 +101,12 @@ impl UserList {
100101
                 let bg_h = self.avatar_size + 28.0 + bg_padding * 2.0; // Include name
101102
 
102103
                 if is_selected {
103
-                    ctx.set_source_rgba(0.3, 0.5, 0.8, 0.3);
104
+                    let ac = &theme.accent;
105
+                    ctx.set_source_rgba(ac.r, ac.g, ac.b, 0.3);
104106
                 } else {
105107
                     ctx.set_source_rgba(1.0, 1.0, 1.0, 0.1);
106108
                 }
107
-                rounded_rectangle(ctx, bg_x, bg_y, bg_w, bg_h, 12.0);
109
+                rounded_rectangle(ctx, bg_x, bg_y, bg_w, bg_h, theme.corner_radius * 0.75);
108110
                 ctx.fill()?;
109111
             }
110112
 
@@ -125,7 +127,7 @@ impl UserList {
125127
 
126128
             // Username below avatar
127129
             let mut font = FontDescription::new();
128
-            font.set_family("Sans");
130
+            font.set_family(&theme.font_family);
129131
             font.set_size(11 * pango::SCALE);
130132
 
131133
             let layout = Layout::new(pango_ctx);
@@ -143,10 +145,11 @@ impl UserList {
143145
             let text_x = item_x + (self.avatar_size - text_w as f64) / 2.0;
144146
             let text_y = item_y + self.avatar_size + 8.0;
145147
 
148
+            let tc = &theme.text_primary;
146149
             if is_selected {
147
-                ctx.set_source_rgb(1.0, 1.0, 1.0);
150
+                ctx.set_source_rgb(tc.r, tc.g, tc.b);
148151
             } else {
149
-                ctx.set_source_rgba(0.9, 0.9, 0.9, 0.9);
152
+                ctx.set_source_rgba(tc.r, tc.g, tc.b, 0.9);
150153
             }
151154
             ctx.move_to(text_x, text_y);
152155
             pangocairo::functions::show_layout(ctx, &layout);