@@ -31,7 +31,7 @@ use monitors::{fallback_config, MonitorConfig}; |
| 31 | 31 | use render::Renderer; |
| 32 | 32 | use transition::{render_with_fade, FadeOutTransition}; |
| 33 | 33 | use widgets::{FocusedField, LoginForm, PowerAction, PowerButtons, SessionSelector, UserList}; |
| 34 | | -use window::GreeterWindow; |
| 34 | +use window::{CursorType, GreeterWindow}; |
| 35 | 35 | |
| 36 | 36 | /// Cursor blink interval |
| 37 | 37 | const CURSOR_BLINK_MS: u64 = 500; |
@@ -60,7 +60,7 @@ async fn main() -> Result<()> { |
| 60 | 60 | ); |
| 61 | 61 | |
| 62 | 62 | // Create X11 window |
| 63 | | - let window = GreeterWindow::new().context("Failed to create window")?; |
| 63 | + let mut window = GreeterWindow::new().context("Failed to create window")?; |
| 64 | 64 | let width = window.width(); |
| 65 | 65 | let height = window.height(); |
| 66 | 66 | tracing::info!(width, height, "Window created"); |
@@ -146,6 +146,13 @@ async fn main() -> Result<()> { |
| 146 | 146 | // Create user list centered on primary monitor (above login form) |
| 147 | 147 | let mut user_list = UserList::new(users, center_x, center_y); |
| 148 | 148 | |
| 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 | + |
| 149 | 156 | // Create session selector (positioned below login form on primary monitor) |
| 150 | 157 | let selector_width = 200.0; |
| 151 | 158 | let selector_x = center_x - selector_width / 2.0; |
@@ -218,6 +225,11 @@ async fn main() -> Result<()> { |
| 218 | 225 | // Power buttons |
| 219 | 226 | power_buttons.render(ctx)?; |
| 220 | 227 | |
| 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 | + |
| 221 | 233 | Ok(()) |
| 222 | 234 | })?; |
| 223 | 235 | } |
@@ -247,6 +259,20 @@ async fn main() -> Result<()> { |
| 247 | 259 | power_buttons.update_hover(mouse_x, mouse_y); |
| 248 | 260 | session_selector.update_hover(mouse_x, mouse_y); |
| 249 | 261 | 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); |
| 250 | 276 | } |
| 251 | 277 | |
| 252 | 278 | Event::ButtonPress(e) => { |
@@ -256,11 +282,41 @@ async fn main() -> Result<()> { |
| 256 | 282 | // Check user list clicks first |
| 257 | 283 | if let Some(username) = user_list.handle_click(click_x, click_y) { |
| 258 | 284 | tracing::info!(username, "User selected from list"); |
| 259 | | - form.username = username; |
| 260 | | - form.password.clear(); |
| 285 | + form.set_username(username); |
| 286 | + form.clear_password(); |
| 261 | 287 | form.focused_field = FocusedField::Password; |
| 262 | 288 | form.clear_messages(); |
| 263 | 289 | } |
| 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 | + } |
| 264 | 320 | // Check power buttons |
| 265 | 321 | else if let Some(action) = power_buttons.handle_click(click_x, click_y) { |
| 266 | 322 | handle_power_action(&mut client, action).await?; |
@@ -326,6 +382,35 @@ async fn main() -> Result<()> { |
| 326 | 382 | form.handle_backspace(); |
| 327 | 383 | } |
| 328 | 384 | |
| 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 | + |
| 329 | 414 | _ => { |
| 330 | 415 | // Regular character key |
| 331 | 416 | if let Some(c) = keycode_to_char(e.detail, e.state) { |
@@ -477,3 +562,56 @@ async fn handle_login( |
| 477 | 562 | form.is_loading = false; |
| 478 | 563 | Ok(None) |
| 479 | 564 | } |
| 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 | +} |