gardesk/gardm / d3fd134

Browse files

Add tooltips, dynamic cursors, and fix rendering issues

Add power button tooltips that appear on hover. Implement
dynamic cursor changes: text cursor over input fields,
pointer cursor over clickable elements.

Auto-select first user on startup and skip to password field.
Add login button click handling.

Fix power icon vertical line direction. Clear Cairo path
state in avatar rendering to prevent path leaks between
draws.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d3fd1342cf8fba258f88cdaf6c78ff8b8ccecf4f
Parents
654cca3
Tree
6f30f4e

5 changed files

StatusFile+-
M gardm-greeter/src/avatar.rs 7 0
M gardm-greeter/src/icons.rs 3 4
M gardm-greeter/src/main.rs 142 4
M gardm-greeter/src/widgets/power_buttons.rs 9 0
M gardm-greeter/src/widgets/user_list.rs 14 21
gardm-greeter/src/avatar.rsmodified
@@ -113,6 +113,7 @@ pub fn render_avatar_image(
113113
 
114114
     // Create circular clip
115115
     ctx.save()?;
116
+    ctx.new_path(); // Clear any previous path
116117
     let radius = size / 2.0;
117118
     ctx.arc(x + radius, y + radius, radius, 0.0, 2.0 * std::f64::consts::PI);
118119
     ctx.clip();
@@ -143,6 +144,9 @@ pub fn render_avatar_fallback(
143144
     let cx = x + radius;
144145
     let cy = y + radius;
145146
 
147
+    // Clear any previous path
148
+    ctx.new_path();
149
+
146150
     // Background circle with hue-based color
147151
     let (r, g, b) = hue_to_rgb(hue);
148152
     ctx.set_source_rgb(r * 0.6, g * 0.6, b * 0.6);
@@ -170,6 +174,9 @@ pub fn render_avatar_fallback(
170174
 
171175
 /// Render avatar with border (for selected state)
172176
 pub fn render_avatar_border(ctx: &Context, x: f64, y: f64, size: f64, selected: bool) -> Result<()> {
177
+    // Clear any previous path
178
+    ctx.new_path();
179
+
173180
     let radius = size / 2.0;
174181
     let cx = x + radius;
175182
     let cy = y + radius;
gardm-greeter/src/icons.rsmodified
@@ -25,10 +25,9 @@ pub fn draw_power(ctx: &Context, x: f64, y: f64, size: f64) {
2525
     ctx.arc(cx, cy, radius, PI / 2.0 + gap_angle, 5.0 * PI / 2.0 - gap_angle);
2626
     ctx.stroke().ok();
2727
 
28
-    // Vertical line at top
29
-    let line_length = 8.0 * scale;
30
-    ctx.move_to(cx, cy - radius + stroke_width);
31
-    ctx.line_to(cx, cy - radius + stroke_width - line_length);
28
+    // Vertical line from top (outside) going down toward center
29
+    ctx.move_to(cx, cy - radius - stroke_width); // Start above the circle gap
30
+    ctx.line_to(cx, cy);                         // Go down to center
3231
     ctx.stroke().ok();
3332
 }
3433
 
gardm-greeter/src/main.rsmodified
@@ -31,7 +31,7 @@ use monitors::{fallback_config, MonitorConfig};
3131
 use render::Renderer;
3232
 use transition::{render_with_fade, FadeOutTransition};
3333
 use widgets::{FocusedField, LoginForm, PowerAction, PowerButtons, SessionSelector, UserList};
34
-use window::GreeterWindow;
34
+use window::{CursorType, GreeterWindow};
3535
 
3636
 /// Cursor blink interval
3737
 const CURSOR_BLINK_MS: u64 = 500;
@@ -60,7 +60,7 @@ async fn main() -> Result<()> {
6060
     );
6161
 
6262
     // Create X11 window
63
-    let window = GreeterWindow::new().context("Failed to create window")?;
63
+    let mut window = GreeterWindow::new().context("Failed to create window")?;
6464
     let width = window.width();
6565
     let height = window.height();
6666
     tracing::info!(width, height, "Window created");
@@ -146,6 +146,13 @@ async fn main() -> Result<()> {
146146
     // Create user list centered on primary monitor (above login form)
147147
     let mut user_list = UserList::new(users, center_x, center_y);
148148
 
149
+    // Auto-select the first user and fill the form
150
+    if let Some(username) = user_list.select_first() {
151
+        tracing::info!(username, "Auto-selected first user");
152
+        form.set_username(username);
153
+        form.focused_field = FocusedField::Password; // Skip to password field
154
+    }
155
+
149156
     // Create session selector (positioned below login form on primary monitor)
150157
     let selector_width = 200.0;
151158
     let selector_x = center_x - selector_width / 2.0;
@@ -218,6 +225,11 @@ async fn main() -> Result<()> {
218225
                 // Power buttons
219226
                 power_buttons.render(ctx)?;
220227
 
228
+                // Power button tooltip
229
+                if let Some((action, center_x, top_y)) = power_buttons.hovered_tooltip_info() {
230
+                    render_tooltip(ctx, &pango_ctx, &theme, action, center_x, top_y)?;
231
+                }
232
+
221233
                 Ok(())
222234
             })?;
223235
         }
@@ -247,6 +259,20 @@ async fn main() -> Result<()> {
247259
                     power_buttons.update_hover(mouse_x, mouse_y);
248260
                     session_selector.update_hover(mouse_x, mouse_y);
249261
                     user_list.update_hover(mouse_x, mouse_y);
262
+
263
+                    // Update cursor based on what's being hovered
264
+                    let cursor = if form.is_over_input(mouse_x, mouse_y) {
265
+                        CursorType::Text
266
+                    } else if power_buttons.hovered_action().is_some()
267
+                        || form.button_contains(mouse_x, mouse_y)
268
+                        || session_selector.button_contains(mouse_x, mouse_y)
269
+                        || user_list.contains(mouse_x, mouse_y)
270
+                    {
271
+                        CursorType::Pointer
272
+                    } else {
273
+                        CursorType::Default
274
+                    };
275
+                    window.set_cursor(cursor);
250276
                 }
251277
 
252278
                 Event::ButtonPress(e) => {
@@ -256,11 +282,41 @@ async fn main() -> Result<()> {
256282
                     // Check user list clicks first
257283
                     if let Some(username) = user_list.handle_click(click_x, click_y) {
258284
                         tracing::info!(username, "User selected from list");
259
-                        form.username = username;
260
-                        form.password.clear();
285
+                        form.set_username(username);
286
+                        form.clear_password();
261287
                         form.focused_field = FocusedField::Password;
262288
                         form.clear_messages();
263289
                     }
290
+                    // Check input field clicks (for cursor placement)
291
+                    else if form.handle_input_click(
292
+                        click_x,
293
+                        click_y,
294
+                        &pango_ctx,
295
+                        &theme.font_family,
296
+                        theme.font_size_normal,
297
+                    ) {
298
+                        // Click was handled by input field
299
+                    }
300
+                    // Check login button click
301
+                    else if form.button_contains(click_x, click_y) && form.can_submit() {
302
+                        // Get selected session exec command
303
+                        let session_exec = session_selector
304
+                            .selected_exec()
305
+                            .unwrap_or("gar-session.sh")
306
+                            .to_string();
307
+
308
+                        // Attempt login
309
+                        if let Some(fade) = handle_login(
310
+                            &mut client,
311
+                            &mut form,
312
+                            &session_exec,
313
+                            config.effective_fade_duration(),
314
+                        )
315
+                        .await?
316
+                        {
317
+                            fade_transition = Some(fade);
318
+                        }
319
+                    }
264320
                     // Check power buttons
265321
                     else if let Some(action) = power_buttons.handle_click(click_x, click_y) {
266322
                         handle_power_action(&mut client, action).await?;
@@ -326,6 +382,35 @@ async fn main() -> Result<()> {
326382
                             form.handle_backspace();
327383
                         }
328384
 
385
+                        keycodes::DELETE => {
386
+                            form.handle_delete();
387
+                        }
388
+
389
+                        keycodes::LEFT => {
390
+                            let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
391
+                            form.handle_left(shift);
392
+                        }
393
+
394
+                        keycodes::RIGHT => {
395
+                            let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
396
+                            form.handle_right(shift);
397
+                        }
398
+
399
+                        keycodes::HOME => {
400
+                            let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
401
+                            form.handle_home(shift);
402
+                        }
403
+
404
+                        keycodes::END => {
405
+                            let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
406
+                            form.handle_end(shift);
407
+                        }
408
+
409
+                        keycodes::UP | keycodes::DOWN => {
410
+                            // Up/down switch between fields
411
+                            form.handle_tab();
412
+                        }
413
+
329414
                         _ => {
330415
                             // Regular character key
331416
                             if let Some(c) = keycode_to_char(e.detail, e.state) {
@@ -477,3 +562,56 @@ async fn handle_login(
477562
     form.is_loading = false;
478563
     Ok(None)
479564
 }
565
+
566
+/// Render a tooltip above a power button
567
+fn render_tooltip(
568
+    ctx: &cairo::Context,
569
+    pango_ctx: &pango::Context,
570
+    theme: &theme::Theme,
571
+    action: PowerAction,
572
+    center_x: f64,
573
+    top_y: f64,
574
+) -> Result<()> {
575
+    use crate::render::rounded_rectangle;
576
+    use pango::{FontDescription, Layout};
577
+
578
+    let text = match action {
579
+        PowerAction::Shutdown => "Shut Down",
580
+        PowerAction::Reboot => "Restart",
581
+        PowerAction::Suspend => "Sleep",
582
+    };
583
+
584
+    // Create text layout to measure
585
+    let layout = Layout::new(pango_ctx);
586
+    let mut font = FontDescription::new();
587
+    font.set_family(&theme.font_family);
588
+    font.set_size(11 * pango::SCALE);
589
+    layout.set_font_description(Some(&font));
590
+    layout.set_text(text);
591
+
592
+    let (text_w, text_h) = layout.pixel_size();
593
+    let padding_x = 10.0;
594
+    let padding_y = 6.0;
595
+    let tooltip_width = text_w as f64 + padding_x * 2.0;
596
+    let tooltip_height = text_h as f64 + padding_y * 2.0;
597
+    let tooltip_x = center_x - tooltip_width / 2.0;
598
+    let tooltip_y = top_y - tooltip_height - 8.0; // 8px gap above button
599
+
600
+    // Background
601
+    ctx.set_source_rgba(0.1, 0.1, 0.1, 0.9);
602
+    rounded_rectangle(ctx, tooltip_x, tooltip_y, tooltip_width, tooltip_height, 6.0);
603
+    ctx.fill()?;
604
+
605
+    // Border
606
+    ctx.set_source_rgba(0.3, 0.3, 0.3, 0.8);
607
+    rounded_rectangle(ctx, tooltip_x, tooltip_y, tooltip_width, tooltip_height, 6.0);
608
+    ctx.set_line_width(1.0);
609
+    ctx.stroke()?;
610
+
611
+    // Text
612
+    ctx.set_source_rgb(0.95, 0.95, 0.95);
613
+    ctx.move_to(tooltip_x + padding_x, tooltip_y + padding_y);
614
+    pangocairo::functions::show_layout(ctx, &layout);
615
+
616
+    Ok(())
617
+}
gardm-greeter/src/widgets/power_buttons.rsmodified
@@ -166,4 +166,13 @@ impl PowerButtons {
166166
             .find(|b| b.hovered)
167167
             .map(|b| b.action)
168168
     }
169
+
170
+    /// Get the hovered button info for tooltip rendering (action, center_x, top_y)
171
+    pub fn hovered_tooltip_info(&self) -> Option<(PowerAction, f64, f64)> {
172
+        self.buttons.iter().find(|b| b.hovered).map(|b| {
173
+            let center_x = b.x + b.size / 2.0;
174
+            let top_y = b.y;
175
+            (b.action, center_x, top_y)
176
+        })
177
+    }
169178
 }
gardm-greeter/src/widgets/user_list.rsmodified
@@ -6,7 +6,6 @@ use crate::avatar::{
66
     get_initials, render_avatar_border, render_avatar_fallback, render_avatar_image,
77
     string_to_hue, AvatarCache,
88
 };
9
-use crate::render::rounded_rectangle;
109
 use crate::theme::Theme;
1110
 use anyhow::Result;
1211
 use cairo::Context;
@@ -40,8 +39,11 @@ impl UserList {
4039
         };
4140
 
4241
         // Center horizontally on primary monitor, position above center
42
+        // Login form is 320px tall, centered, so top is at center_y - 160
43
+        // Avatar (64px) + gap (8px) + name text (~20px) = ~92px
44
+        // Need enough clearance above the login form
4345
         let x = center_x - total_width / 2.0;
44
-        let y = center_y - 220.0; // Above the login form
46
+        let y = center_y - 280.0; // Above the login form with breathing room
4547
 
4648
         Self {
4749
             users,
@@ -60,6 +62,16 @@ impl UserList {
6062
         self.users.is_empty()
6163
     }
6264
 
65
+    /// Select the first user and return their username
66
+    pub fn select_first(&mut self) -> Option<String> {
67
+        if self.users.is_empty() {
68
+            None
69
+        } else {
70
+            self.selected_index = Some(0);
71
+            Some(self.users[0].name.clone())
72
+        }
73
+    }
74
+
6375
     /// Get the selected user's username
6476
     pub fn selected_username(&self) -> Option<&str> {
6577
         self.selected_index
@@ -90,25 +102,6 @@ impl UserList {
90102
             let item_y = self.y;
91103
 
92104
             let is_selected = self.selected_index == Some(i);
93
-            let is_hovered = self.hovered_index == Some(i);
94
-
95
-            // Hover/selection background
96
-            if is_hovered || is_selected {
97
-                let bg_padding = 8.0;
98
-                let bg_x = item_x - bg_padding;
99
-                let bg_y = item_y - bg_padding;
100
-                let bg_w = self.avatar_size + bg_padding * 2.0;
101
-                let bg_h = self.avatar_size + 28.0 + bg_padding * 2.0; // Include name
102
-
103
-                if is_selected {
104
-                    let ac = &theme.accent;
105
-                    ctx.set_source_rgba(ac.r, ac.g, ac.b, 0.3);
106
-                } else {
107
-                    ctx.set_source_rgba(1.0, 1.0, 1.0, 0.1);
108
-                }
109
-                rounded_rectangle(ctx, bg_x, bg_y, bg_w, bg_h, theme.corner_radius * 0.75);
110
-                ctx.fill()?;
111
-            }
112105
 
113106
             // Avatar
114107
             let home = user.home.to_str();