gardesk/gardm / 83a28a8

Browse files

multi-monitor support via XRandR

Add monitor detection module using RandR to detect connected monitors,
their positions, and primary display. UI elements now center on the
primary monitor instead of the full virtual screen.

Changes:
- Add monitors.rs with MonitorConfig and Monitor structs
- Add randr feature to x11rb dependency
- Update LoginForm, UserList, PowerButtons to accept center coordinates
- Add root() accessor to GreeterWindow for RandR queries
- Add sprint documentation for sprints 5-8
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
83a28a8cec6e592db8ed5fad300967f1995d61e3
Parents
87f2d5f
Tree
034004a

11 changed files

StatusFile+-
M Cargo.toml 1 1
A docs/sprints/sprint-5-power-session.md 38 0
A docs/sprints/sprint-6-user-avatars.md 43 0
A docs/sprints/sprint-7-accessibility.md 58 0
A docs/sprints/sprint-8-multimonitor.md 105 0
M gardm-greeter/src/main.rs 45 10
A gardm-greeter/src/monitors.rs 203 0
M gardm-greeter/src/widgets/login_form.rs 4 4
M gardm-greeter/src/widgets/power_buttons.rs 7 12
M gardm-greeter/src/widgets/user_list.rs 11 6
M gardm-greeter/src/window.rs 10 0
Cargo.tomlmodified
@@ -30,7 +30,7 @@ nix = { version = "0.27", features = ["user", "process", "signal"] }
3030
 pam-client = "0.5"
3131
 
3232
 # X11
33
-x11rb = { version = "0.13", features = ["allow-unsafe-code"] }
33
+x11rb = { version = "0.13", features = ["allow-unsafe-code", "randr"] }
3434
 
3535
 # Graphics
3636
 cairo-rs = { version = "0.18", features = ["png"] }
docs/sprints/sprint-5-power-session.mdadded
@@ -0,0 +1,38 @@
1
+# Sprint 5: Power Buttons and Session Selector
2
+
3
+**Goal:** Add power control buttons (shutdown, reboot, suspend) and a session selector dropdown to the greeter UI.
4
+
5
+## Completed Features
6
+
7
+### Power Buttons
8
+- Custom Cairo-drawn icons (no external dependencies):
9
+  - Power symbol (circle with vertical line gap at top)
10
+  - Reboot (circular arrows)
11
+  - Suspend (crescent moon)
12
+- Bottom-right corner positioning
13
+- Hover effects with color-coded highlights:
14
+  - Shutdown: Red tint
15
+  - Reboot: Blue tint
16
+  - Suspend: Gold tint
17
+- Semi-transparent backgrounds that glow on hover
18
+- IPC integration for power actions
19
+
20
+### Session Selector
21
+- Dropdown positioned below login form
22
+- Lists sessions from daemon via `ListSessions` IPC
23
+- Dropdown opens upward above the button
24
+- Hover highlighting on items
25
+- Checkmark indicator on selected session
26
+- Selected session's exec command used for login
27
+
28
+### Supporting Changes
29
+- Added `POINTER_MOTION` to X11 event mask for hover tracking
30
+- Mouse position tracking in main loop
31
+- Click handling for interactive elements
32
+
33
+## Files Added/Modified
34
+- `gardm-greeter/src/icons.rs` - Custom icon drawing functions
35
+- `gardm-greeter/src/widgets/power_buttons.rs` - Power button widget
36
+- `gardm-greeter/src/widgets/session_selector.rs` - Session dropdown widget
37
+- `gardm-greeter/src/main.rs` - Event handling integration
38
+- `gardm-greeter/src/window.rs` - Mouse motion events
docs/sprints/sprint-6-user-avatars.mdadded
@@ -0,0 +1,43 @@
1
+# Sprint 6: User List with Avatars
2
+
3
+**Goal:** Display available users with circular avatars for quick selection, allowing click-to-login workflow.
4
+
5
+## Completed Features
6
+
7
+### Avatar Loading
8
+- Searches standard avatar locations:
9
+  - `~/.face` (freedesktop standard)
10
+  - `~/.face.icon`
11
+  - `~/.face.png`
12
+  - `~/.config/face.png`
13
+  - `/var/lib/AccountsService/icons/<username>`
14
+- Image processing:
15
+  - Center crop to square
16
+  - Scale with Lanczos3 filter for quality
17
+  - RGBA to BGRA conversion for Cairo
18
+- Caching to avoid reloading
19
+
20
+### Fallback Avatars
21
+- Colored circle with user initials
22
+- Hue derived from username hash for consistency
23
+- First + last initial for full names, single initial for usernames
24
+
25
+### User List Widget
26
+- Horizontal row positioned above login form
27
+- Circular avatar rendering with clipping
28
+- Username/first name displayed below avatar
29
+- Hover effect: semi-transparent highlight background
30
+- Selection effect: blue ring around avatar
31
+- Click behavior:
32
+  - Populates username field
33
+  - Clears password field
34
+  - Focuses password field
35
+  - Clears any error messages
36
+
37
+## Files Added
38
+- `gardm-greeter/src/avatar.rs` - Avatar loading and rendering
39
+- `gardm-greeter/src/widgets/user_list.rs` - User list widget
40
+
41
+## IPC Integration
42
+- Uses `ListUsers` request to get available users
43
+- User info includes: name, full_name, home directory, avatar path
docs/sprints/sprint-7-accessibility.mdadded
@@ -0,0 +1,58 @@
1
+# Sprint 7: Accessibility and Theming
2
+
3
+**Goal:** Improve accessibility features and add theming support for visual customization.
4
+
5
+## Planned Features
6
+
7
+### Accessibility
8
+- [ ] High contrast mode
9
+- [ ] Larger text option
10
+- [ ] Keyboard navigation indicators (visible focus rings)
11
+- [ ] Screen reader hints (if feasible with X11)
12
+- [ ] Reduced motion option (disable fade transitions)
13
+
14
+### Theming
15
+- [ ] Theme configuration in `/etc/gardm/theme.toml`
16
+- [ ] Customizable colors:
17
+  - Background overlay color/opacity
18
+  - Panel background color
19
+  - Text colors (primary, secondary, error, info)
20
+  - Accent color for focus/selection
21
+  - Button colors
22
+- [ ] Custom font selection
23
+- [ ] Custom logo/branding image
24
+- [ ] Corner radius customization
25
+
26
+### Configuration Options
27
+```toml
28
+[theme]
29
+# Colors (CSS-style hex or rgba)
30
+background_overlay = "rgba(0, 0, 0, 0.6)"
31
+panel_background = "rgba(20, 20, 20, 0.9)"
32
+text_primary = "#ffffff"
33
+text_secondary = "#cccccc"
34
+text_error = "#ff6b6b"
35
+text_info = "#6bff6b"
36
+accent = "#4a90d9"
37
+
38
+# Typography
39
+font_family = "Sans"
40
+font_size_normal = 14
41
+font_size_large = 18
42
+font_size_title = 24
43
+
44
+# Layout
45
+corner_radius = 16
46
+panel_padding = 24
47
+
48
+[accessibility]
49
+high_contrast = false
50
+large_text = false
51
+reduce_motion = false
52
+```
53
+
54
+### Implementation Notes
55
+- Theme loaded at startup from config
56
+- Fallback to defaults if theme file missing
57
+- Colors parsed from hex (#RRGGBB) or rgba() format
58
+- High contrast mode overrides theme colors with maximum contrast values
docs/sprints/sprint-8-multimonitor.mdadded
@@ -0,0 +1,105 @@
1
+# Sprint 8: Multi-Monitor Support
2
+
3
+**Goal:** Properly handle multi-monitor setups by detecting all monitors via RandR and positioning the UI appropriately.
4
+
5
+## Objectives
6
+
7
+1. Detect all connected monitors using XRandR
8
+2. Create window spanning entire virtual screen
9
+3. Render background on all monitors
10
+4. Center login UI on primary monitor
11
+5. Handle monitor hotplug events (optional)
12
+
13
+## Implementation
14
+
15
+### Monitor Detection
16
+```rust
17
+// Using x11rb's randr extension
18
+use x11rb::protocol::randr::{self, ConnectionExt as _};
19
+
20
+struct Monitor {
21
+    x: i16,
22
+    y: i16,
23
+    width: u16,
24
+    height: u16,
25
+    primary: bool,
26
+    name: String,
27
+}
28
+
29
+fn get_monitors(conn: &RustConnection, root: Window) -> Result<Vec<Monitor>> {
30
+    let resources = conn.randr_get_screen_resources(root)?.reply()?;
31
+    let mut monitors = Vec::new();
32
+
33
+    for output in resources.outputs {
34
+        let output_info = conn.randr_get_output_info(output, 0)?.reply()?;
35
+        if output_info.connection == randr::Connection::CONNECTED {
36
+            if let Some(crtc) = output_info.crtc {
37
+                let crtc_info = conn.randr_get_crtc_info(crtc, 0)?.reply()?;
38
+                monitors.push(Monitor {
39
+                    x: crtc_info.x,
40
+                    y: crtc_info.y,
41
+                    width: crtc_info.width,
42
+                    height: crtc_info.height,
43
+                    primary: false, // Check via randr_get_output_primary
44
+                    name: String::from_utf8_lossy(&output_info.name).to_string(),
45
+                });
46
+            }
47
+        }
48
+    }
49
+
50
+    // Mark primary
51
+    let primary = conn.randr_get_output_primary(root)?.reply()?;
52
+    // ... mark the matching monitor as primary
53
+
54
+    Ok(monitors)
55
+}
56
+```
57
+
58
+### Window Spanning
59
+- Window covers entire virtual screen (union of all monitors)
60
+- Use root window dimensions for total size
61
+- Background rendered per-monitor with proper offsets
62
+
63
+### UI Centering
64
+- Find primary monitor (or largest if no primary set)
65
+- Calculate center point of primary monitor
66
+- Offset all UI element positions relative to primary center
67
+- Login form, user list, session selector all centered on primary
68
+
69
+### Background Rendering
70
+- Load background image once
71
+- For each monitor:
72
+  - Scale/crop background to monitor dimensions
73
+  - Apply blur and brightness
74
+  - Render at monitor's x,y offset in the virtual screen
75
+
76
+### Example Layout
77
+```
78
+┌─────────────────┬─────────────────────────┐
79
+│                 │                         │
80
+│   Monitor 2     │      Monitor 1          │
81
+│   1920x1080     │      2560x1440          │
82
+│   (secondary)   │      (primary)          │
83
+│                 │                         │
84
+│   [background]  │   [background + UI]     │
85
+│                 │                         │
86
+└─────────────────┴─────────────────────────┘
87
+```
88
+
89
+## Testing
90
+```bash
91
+# Test with Xephyr multi-head (limited)
92
+# Better: test on actual multi-monitor setup
93
+
94
+# Check detected monitors
95
+xrandr --query
96
+
97
+# Verify greeter spans all monitors
98
+DISPLAY=:0 ./target/release/gardm-greeter
99
+```
100
+
101
+## Files to Modify
102
+- `gardm-greeter/src/window.rs` - Add RandR detection
103
+- `gardm-greeter/src/main.rs` - Pass monitor info to widgets
104
+- `gardm-greeter/src/background.rs` - Per-monitor rendering
105
+- Widget files - Accept center offset parameter
gardm-greeter/src/main.rsmodified
@@ -8,6 +8,7 @@ mod config;
88
 mod garbg;
99
 mod icons;
1010
 mod keyboard;
11
+mod monitors;
1112
 mod render;
1213
 mod transition;
1314
 mod widgets;
@@ -25,6 +26,7 @@ use background::{load_blurred_background, render_to_cairo, solid_background};
2526
 use config::GreeterConfig;
2627
 use garbg::WallpaperResolver;
2728
 use keyboard::{keycode_to_char, keycodes};
29
+use monitors::{fallback_config, MonitorConfig};
2830
 use render::Renderer;
2931
 use transition::{render_with_fade, FadeOutTransition};
3032
 use widgets::{FocusedField, LoginForm, PowerAction, PowerButtons, SessionSelector, UserList};
@@ -53,7 +55,35 @@ async fn main() -> Result<()> {
5355
     let height = window.height();
5456
     tracing::info!(width, height, "Window created");
5557
 
56
-    // Create renderer
58
+    // Detect monitors using RandR
59
+    let monitor_config = MonitorConfig::detect(window.conn(), window.root())
60
+        .unwrap_or_else(|e| {
61
+            tracing::warn!("Failed to detect monitors: {}, using fallback", e);
62
+            fallback_config(width, height)
63
+        });
64
+
65
+    // Get the primary monitor for UI positioning
66
+    let primary = monitor_config.primary_or_first().cloned().unwrap_or_else(|| {
67
+        monitors::Monitor {
68
+            x: 0,
69
+            y: 0,
70
+            width,
71
+            height,
72
+            primary: true,
73
+            name: "fallback".to_string(),
74
+        }
75
+    });
76
+
77
+    let center_x = primary.center_x();
78
+    let center_y = primary.center_y();
79
+    tracing::info!(
80
+        monitor = %primary.name,
81
+        center_x,
82
+        center_y,
83
+        "UI centered on primary monitor"
84
+    );
85
+
86
+    // Create renderer for the full virtual screen
5787
     let mut renderer = Renderer::new(width, height).context("Failed to create renderer")?;
5888
 
5989
     // Resolve wallpaper using garbg integration
@@ -82,8 +112,8 @@ async fn main() -> Result<()> {
82112
         }
83113
     };
84114
 
85
-    // Create login form
86
-    let mut form = LoginForm::new(width as f64, height as f64);
115
+    // Create login form centered on primary monitor
116
+    let mut form = LoginForm::new(center_x, center_y);
87117
 
88118
     // Connect to daemon
89119
     let mut client = Client::connect().await.context("Failed to connect to gardmd")?;
@@ -103,17 +133,22 @@ async fn main() -> Result<()> {
103133
     };
104134
     tracing::debug!(count = users.len(), "Available users");
105135
 
106
-    // Create user list (above login form)
107
-    let mut user_list = UserList::new(users, width as f64, height as f64);
136
+    // Create user list centered on primary monitor (above login form)
137
+    let mut user_list = UserList::new(users, center_x, center_y);
108138
 
109
-    // Create session selector (positioned below login form)
139
+    // Create session selector (positioned below login form on primary monitor)
110140
     let selector_width = 200.0;
111
-    let selector_x = (width as f64 - selector_width) / 2.0;
112
-    let selector_y = height as f64 / 2.0 + 180.0; // Below the login form
141
+    let selector_x = center_x - selector_width / 2.0;
142
+    let selector_y = center_y + 180.0; // Below the login form
113143
     let mut session_selector = SessionSelector::new(sessions, selector_x, selector_y, selector_width);
114144
 
115
-    // Create power buttons (bottom-right corner)
116
-    let mut power_buttons = PowerButtons::new(width as f64, height as f64);
145
+    // Create power buttons (bottom-right corner of primary monitor)
146
+    let mut power_buttons = PowerButtons::new(
147
+        primary.x as f64,
148
+        primary.y as f64,
149
+        primary.width as f64,
150
+        primary.height as f64,
151
+    );
117152
 
118153
     // Create Pango context for text rendering
119154
     let pango_ctx = pangocairo::functions::create_context(&renderer.context()?);
gardm-greeter/src/monitors.rsadded
@@ -0,0 +1,203 @@
1
+//! Multi-monitor detection using XRandR
2
+//!
3
+//! Detects connected monitors and their positions for proper UI placement.
4
+
5
+use anyhow::{Context, Result};
6
+use x11rb::connection::Connection;
7
+use x11rb::protocol::randr::{self, ConnectionExt as RandrConnectionExt};
8
+use x11rb::protocol::xproto::Window;
9
+use x11rb::rust_connection::RustConnection;
10
+
11
+/// Information about a connected monitor
12
+#[derive(Debug, Clone)]
13
+pub struct Monitor {
14
+    /// X position in virtual screen
15
+    pub x: i16,
16
+    /// Y position in virtual screen
17
+    pub y: i16,
18
+    /// Width in pixels
19
+    pub width: u16,
20
+    /// Height in pixels
21
+    pub height: u16,
22
+    /// Whether this is the primary monitor
23
+    pub primary: bool,
24
+    /// Monitor name (e.g., "DP-1", "HDMI-A-1")
25
+    pub name: String,
26
+}
27
+
28
+impl Monitor {
29
+    /// Get the center X coordinate of this monitor
30
+    pub fn center_x(&self) -> f64 {
31
+        self.x as f64 + self.width as f64 / 2.0
32
+    }
33
+
34
+    /// Get the center Y coordinate of this monitor
35
+    pub fn center_y(&self) -> f64 {
36
+        self.y as f64 + self.height as f64 / 2.0
37
+    }
38
+}
39
+
40
+/// Detected monitor configuration
41
+#[derive(Debug, Clone)]
42
+pub struct MonitorConfig {
43
+    /// All connected monitors
44
+    pub monitors: Vec<Monitor>,
45
+    /// Total virtual screen width
46
+    pub total_width: u16,
47
+    /// Total virtual screen height
48
+    pub total_height: u16,
49
+}
50
+
51
+impl MonitorConfig {
52
+    /// Detect monitors using RandR
53
+    pub fn detect(conn: &RustConnection, root: Window) -> Result<Self> {
54
+        // Get screen resources
55
+        let resources = conn
56
+            .randr_get_screen_resources(root)
57
+            .context("Failed to get screen resources")?
58
+            .reply()
59
+            .context("Failed to get screen resources reply")?;
60
+
61
+        // Get primary output
62
+        let primary_output = conn
63
+            .randr_get_output_primary(root)
64
+            .context("Failed to get primary output")?
65
+            .reply()
66
+            .context("Failed to get primary output reply")?
67
+            .output;
68
+
69
+        let mut monitors = Vec::new();
70
+
71
+        for output in &resources.outputs {
72
+            let output_info = match conn.randr_get_output_info(*output, 0) {
73
+                Ok(cookie) => match cookie.reply() {
74
+                    Ok(info) => info,
75
+                    Err(_) => continue,
76
+                },
77
+                Err(_) => continue,
78
+            };
79
+
80
+            // Skip disconnected outputs
81
+            if output_info.connection != randr::Connection::CONNECTED {
82
+                continue;
83
+            }
84
+
85
+            // Skip outputs without a CRTC (not active)
86
+            let crtc = match output_info.crtc {
87
+                0 => continue,
88
+                c => c,
89
+            };
90
+
91
+            let crtc_info = match conn.randr_get_crtc_info(crtc, 0) {
92
+                Ok(cookie) => match cookie.reply() {
93
+                    Ok(info) => info,
94
+                    Err(_) => continue,
95
+                },
96
+                Err(_) => continue,
97
+            };
98
+
99
+            // Skip CRTCs with zero dimensions
100
+            if crtc_info.width == 0 || crtc_info.height == 0 {
101
+                continue;
102
+            }
103
+
104
+            let name = String::from_utf8_lossy(&output_info.name).to_string();
105
+            let is_primary = *output == primary_output;
106
+
107
+            monitors.push(Monitor {
108
+                x: crtc_info.x,
109
+                y: crtc_info.y,
110
+                width: crtc_info.width,
111
+                height: crtc_info.height,
112
+                primary: is_primary,
113
+                name,
114
+            });
115
+        }
116
+
117
+        // Calculate total virtual screen size
118
+        let (total_width, total_height) = if monitors.is_empty() {
119
+            // Fallback to root window size
120
+            let screen = &conn.setup().roots[0];
121
+            (screen.width_in_pixels, screen.height_in_pixels)
122
+        } else {
123
+            let max_x = monitors
124
+                .iter()
125
+                .map(|m| m.x as i32 + m.width as i32)
126
+                .max()
127
+                .unwrap_or(0);
128
+            let max_y = monitors
129
+                .iter()
130
+                .map(|m| m.y as i32 + m.height as i32)
131
+                .max()
132
+                .unwrap_or(0);
133
+            (max_x as u16, max_y as u16)
134
+        };
135
+
136
+        // If no primary is set, mark the largest monitor as primary
137
+        if !monitors.iter().any(|m| m.primary) && !monitors.is_empty() {
138
+            let largest_idx = monitors
139
+                .iter()
140
+                .enumerate()
141
+                .max_by_key(|(_, m)| m.width as u32 * m.height as u32)
142
+                .map(|(i, _)| i)
143
+                .unwrap_or(0);
144
+            monitors[largest_idx].primary = true;
145
+        }
146
+
147
+        tracing::info!(
148
+            count = monitors.len(),
149
+            total_width,
150
+            total_height,
151
+            "Detected monitors"
152
+        );
153
+
154
+        for monitor in &monitors {
155
+            tracing::debug!(
156
+                name = %monitor.name,
157
+                x = monitor.x,
158
+                y = monitor.y,
159
+                width = monitor.width,
160
+                height = monitor.height,
161
+                primary = monitor.primary,
162
+                "Monitor"
163
+            );
164
+        }
165
+
166
+        Ok(Self {
167
+            monitors,
168
+            total_width,
169
+            total_height,
170
+        })
171
+    }
172
+
173
+    /// Get the primary monitor
174
+    pub fn primary(&self) -> Option<&Monitor> {
175
+        self.monitors.iter().find(|m| m.primary)
176
+    }
177
+
178
+    /// Get the primary monitor, or the first one if no primary
179
+    pub fn primary_or_first(&self) -> Option<&Monitor> {
180
+        self.primary().or_else(|| self.monitors.first())
181
+    }
182
+
183
+    /// Check if this is a single-monitor setup
184
+    pub fn is_single_monitor(&self) -> bool {
185
+        self.monitors.len() <= 1
186
+    }
187
+}
188
+
189
+/// Fallback monitor config when RandR fails
190
+pub fn fallback_config(screen_width: u16, screen_height: u16) -> MonitorConfig {
191
+    MonitorConfig {
192
+        monitors: vec![Monitor {
193
+            x: 0,
194
+            y: 0,
195
+            width: screen_width,
196
+            height: screen_height,
197
+            primary: true,
198
+            name: "default".to_string(),
199
+        }],
200
+        total_width: screen_width,
201
+        total_height: screen_height,
202
+    }
203
+}
gardm-greeter/src/widgets/login_form.rsmodified
@@ -32,8 +32,8 @@ pub struct LoginForm {
3232
 }
3333
 
3434
 impl LoginForm {
35
-    /// Create a new login form centered on the screen
36
-    pub fn new(screen_width: f64, screen_height: f64) -> Self {
35
+    /// Create a new login form centered at the specified point
36
+    pub fn new(center_x: f64, center_y: f64) -> Self {
3737
         let width = 400.0;
3838
         let height = 320.0;
3939
 
@@ -45,8 +45,8 @@ impl LoginForm {
4545
             info_message: None,
4646
             is_loading: false,
4747
             cursor_visible: true,
48
-            x: (screen_width - width) / 2.0,
49
-            y: (screen_height - height) / 2.0,
48
+            x: center_x - width / 2.0,
49
+            y: center_y - height / 2.0,
5050
             width,
5151
             height,
5252
         }
gardm-greeter/src/widgets/power_buttons.rsmodified
@@ -87,20 +87,19 @@ impl PowerButton {
8787
 /// Power buttons panel
8888
 pub struct PowerButtons {
8989
     buttons: Vec<PowerButton>,
90
-    screen_width: f64,
91
-    screen_height: f64,
9290
 }
9391
 
9492
 impl PowerButtons {
95
-    /// Create power buttons positioned in bottom-right corner
96
-    pub fn new(screen_width: f64, screen_height: f64) -> Self {
93
+    /// Create power buttons positioned in bottom-right corner of given area
94
+    /// For multi-monitor: pass the primary monitor's x, y, width, height
95
+    pub fn new(area_x: f64, area_y: f64, area_width: f64, area_height: f64) -> Self {
9796
         let button_size = 48.0;
9897
         let spacing = 12.0;
9998
         let margin = 24.0;
10099
 
101
-        // Position in bottom-right corner
102
-        let start_x = screen_width - margin - (button_size * 3.0 + spacing * 2.0);
103
-        let y = screen_height - margin - button_size;
100
+        // Position in bottom-right corner of the area
101
+        let start_x = area_x + area_width - margin - (button_size * 3.0 + spacing * 2.0);
102
+        let y = area_y + area_height - margin - button_size;
104103
 
105104
         let buttons = vec![
106105
             PowerButton::new(PowerAction::Suspend, start_x, y, button_size),
@@ -118,11 +117,7 @@ impl PowerButtons {
118117
             ),
119118
         ];
120119
 
121
-        Self {
122
-            buttons,
123
-            screen_width,
124
-            screen_height,
125
-        }
120
+        Self { buttons }
126121
     }
127122
 
128123
     /// Render all power buttons
gardm-greeter/src/widgets/user_list.rsmodified
@@ -28,14 +28,19 @@ pub struct UserList {
2828
 
2929
 impl UserList {
3030
     /// Create a new user list centered above the login form
31
-    pub fn new(users: Vec<UserInfo>, screen_width: f64, screen_height: f64) -> Self {
31
+    /// center_x, center_y is the center point of the primary monitor
32
+    pub fn new(users: Vec<UserInfo>, center_x: f64, center_y: f64) -> Self {
3233
         let avatar_size = 64.0;
3334
         let spacing = 24.0;
34
-        let total_width = users.len() as f64 * (avatar_size + spacing) - spacing;
35
-
36
-        // Center horizontally, position above center
37
-        let x = (screen_width - total_width) / 2.0;
38
-        let y = screen_height / 2.0 - 220.0; // Above the login form
35
+        let total_width = if users.is_empty() {
36
+            0.0
37
+        } else {
38
+            users.len() as f64 * (avatar_size + spacing) - spacing
39
+        };
40
+
41
+        // Center horizontally on primary monitor, position above center
42
+        let x = center_x - total_width / 2.0;
43
+        let y = center_y - 220.0; // Above the login form
3944
 
4045
         Self {
4146
             users,
gardm-greeter/src/window.rsmodified
@@ -133,6 +133,16 @@ impl GreeterWindow {
133133
         self.window
134134
     }
135135
 
136
+    /// Get the root window ID
137
+    pub fn root(&self) -> Window {
138
+        self.conn.setup().roots[self.screen_num].root
139
+    }
140
+
141
+    /// Get the screen number
142
+    pub fn screen_num(&self) -> usize {
143
+        self.screen_num
144
+    }
145
+
136146
     /// Put an ARGB image to the window
137147
     pub fn put_image(&self, data: &[u8]) -> Result<()> {
138148
         self.conn