From 7ac3fdfc85479fd9842eec32afb7476ca3b98c2b Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 20:29:38 +0800 Subject: [PATCH] breadgreet: bring the login UI up to the lock screen's polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breadgreet's stylesheet was a crude subset of what `design/sketch.html` specifies for `.greetoverlay`. Ported it faithfully so the greeter and the lock screen read as one visual identity: - Card: proper elevation (`box-shadow: 0 6px 28px`), a hairline `alpha(@overlay, 0.09)` border, 10px radius, 300px min-width. - Entry: recessed `shade(@surface, 1.5)` fill, and an accent focus ring (`border-color: @accent` + `box-shadow: 0 0 0 2px alpha(@accent, .28)`) matching the sketch's `.gentry:focus`. - Clock/date over the wallpaper get a soft text-shadow for legibility on any background; sizes match the sketch (46 / 14). - Session picker styled as a surface pill (button + popover), not a bare GtkDropDown. - A CSS-animated ring shown while a greetd request is in flight (`sync_busy`, driven from the single `update` exit point). - Shake on a failed attempt (`flash_error`), matching the lock screen's wrong-password motion — fired from both `AppInput::Error` and a PAM `AuthPrompt::Error`. Uses the shared `@define-color` palette (`@surface`, `@overlay`, `@accent`, `@red`) so it recolours with the theme, same as breadbar. `cargo test --workspace --all-targets` (157) / `clippy --all-targets -D warnings` / `fmt --check` all clean. Visual pass still wanted — the CSS is a direct translation of the mockup, not screenshot-verified. --- breadgreet/src/main.rs | 44 ++++++++++++++- breadgreet/src/theme.rs | 120 ++++++++++++++++++++++++++++++++++------ 2 files changed, 145 insertions(+), 19 deletions(-) diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 935ad44..07aaae1 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -46,6 +46,8 @@ struct App { date_lbl: gtk4::Label, status_lbl: gtk4::Label, entry: gtk4::Entry, + card: gtk4::Box, + spinner: gtk4::Box, stage: Stage, username: String, sessions: Vec, @@ -168,10 +170,18 @@ impl SimpleComponent for App { dropdown.upcast() }; - let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + // A CSS-animated ring (not GtkSpinner) so it matches the design + // sketch exactly: 2px track, accent top, shown only while a greetd + // request is in flight (see `set_busy`). + let spinner = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spinner.add_css_class("login-spinner"); + spinner.set_halign(gtk4::Align::Center); + + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0); card.add_css_class("login-card"); card.append(&entry); card.append(&status_lbl); + card.append(&spinner); card.append(&session_widget); let widgets = view_output!(); @@ -243,6 +253,8 @@ impl SimpleComponent for App { date_lbl, status_lbl, entry, + card, + spinner, stage: Stage::Username, username: String::new(), sessions, @@ -289,10 +301,38 @@ impl SimpleComponent for App { } AppInput::Cancel => self.cancel_auth(), } + self.sync_busy(); } } impl App { + /// Spinner runs exactly while a greetd request is in flight. Driven from + /// one place (the end of `update`) so every path — submit, prompt, + /// cancel, error — stays in sync with `self.stage`. + fn sync_busy(&self) { + let busy = matches!(self.stage, Stage::Working | Stage::Starting); + if busy { + self.spinner.add_css_class("spinning"); + } else { + self.spinner.remove_css_class("spinning"); + } + } + + /// One-shot shake on the card, matching the lock screen's wrong-password + /// motion. Re-armed each call by dropping the class first (a running + /// animation won't restart just from re-adding it). + fn flash_error(&self) { + self.card.remove_css_class("shake"); + let card = self.card.clone(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(10), move || { + card.add_css_class("shake"); + let card2 = card.clone(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(420), move || { + card2.remove_css_class("shake"); + }); + }); + } + fn handle_submit(&mut self) { if matches!(self.stage, Stage::Working | Stage::Starting) { return; @@ -338,6 +378,7 @@ impl App { AuthPrompt::Error(message) => { self.status_lbl.add_css_class("error"); self.status_lbl.set_label(&message); + self.flash_error(); self.pam_status_held = true; self.dispatch(greetd::Command::Respond(None)); } @@ -403,6 +444,7 @@ impl App { self.status_lbl.remove_css_class("error"); } else { self.status_lbl.add_css_class("error"); + self.flash_error(); } self.reset_to_username(); } diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 96b4360..972a136 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -1,4 +1,14 @@ -use bread_theme::{gtk as bgtk, ink_on, load_palette}; +//! breadgreet's app stylesheet. +//! +//! Layered on top of `bread_theme`'s shared `@define-color` palette +//! (`@surface`, `@overlay`, `@accent`, `@red`, …) via `apply_shared()` + +//! `apply_app_css()`. The visual target is `design/sketch.html`'s +//! `.greetoverlay` section — the same design language as the lock screen: +//! a floating surface card over a cover-fit / Ken-Burns wallpaper, an +//! accent focus ring on the entry, a spinner while a request is in flight, +//! and a shake on a failed attempt. + +use bread_theme::gtk as bgtk; use gtk4::CssProvider; use std::cell::RefCell; @@ -15,25 +25,99 @@ fn css_font_family(family: &str) -> String { } fn load_css(font_family: &str) -> String { - let p = load_palette(); let font = css_font_family(font_family); format!( - "window.breadgreet {{ background-color: {bg}; color: {on_bg}; {font} }}\ - .login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ - padding: 20px; min-width: 320px; }}\ - .login-clock {{ font-size: 48px; font-weight: bold; }}\ - .login-date {{ font-size: 18px; font-weight: 500; opacity: 0.8; }}\ - .login-entry {{ font-size: 14px; }}\ - .login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ - .login-status.error {{ color: {red}; opacity: 1; }}\ - .login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\ - dropdown.login-session {{ min-height: 32px; }}\ - .login-veil {{ background-image: linear-gradient(to bottom, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 100%); }}", - bg = p.background, - surface = p.color0, - red = p.color1, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), + // ---- window + wallpaper ---------------------------------------- + "window.breadgreet {{ background-color: @bg; color: @on-bg; {font} }}\ + .login-veil {{\ + background-image: linear-gradient(to bottom,\ + alpha(black, 0.42) 0%, alpha(black, 0.20) 45%, alpha(black, 0.34) 100%);\ + }}\ + \ + /* ---- clock cluster (over the wallpaper) --------------------- */\ + .login-clock {{\ + font-size: 46px; font-weight: 700; color: white;\ + text-shadow: 0 2px 12px alpha(black, 0.55);\ + }}\ + .login-date {{\ + font-size: 14px; font-weight: 500; color: alpha(white, 0.82);\ + text-shadow: 0 1px 8px alpha(black, 0.5);\ + }}\ + \ + /* ---- the card --------------------------------------------- */\ + .login-card {{\ + background: @surface; color: @on-surface;\ + border: 1px solid alpha(@overlay, 0.09);\ + border-radius: 10px; padding: 22px;\ + min-width: 300px;\ + box-shadow: 0 6px 28px alpha(black, 0.5);\ + }}\ + .login-card.shake {{ animation: breadgreet-shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97); }}\ + \ + /* ---- entry ----------------------------------------------- */\ + .login-entry {{\ + background: shade(@surface, 1.5); color: @on-surface;\ + border: 1px solid alpha(@overlay, 0.14);\ + border-radius: 7px; padding: 11px 14px; font-size: 14px;\ + caret-color: @accent;\ + transition: border-color 180ms ease, box-shadow 180ms ease;\ + }}\ + .login-entry:disabled {{ opacity: 0.55; }}\ + .login-entry > text {{ background: transparent; }}\ + .login-entry:focus-within {{\ + border-color: @accent;\ + box-shadow: 0 0 0 2px alpha(@accent, 0.28);\ + outline: none;\ + }}\ + \ + /* ---- status line --------------------------------------- */\ + .login-status {{\ + font-size: 12px; color: alpha(@on-surface, 0.72);\ + margin-top: 10px; min-height: 1em;\ + transition: color 160ms ease;\ + }}\ + .login-status.error {{ color: @red; opacity: 1; font-weight: 700; }}\ + \ + /* ---- spinner (shown while a request is in flight) ------ */\ + .login-spinner {{\ + min-width: 16px; min-height: 16px;\ + margin-top: 12px;\ + border: 2px solid alpha(@overlay, 0.15);\ + border-top: 2px solid @accent;\ + border-radius: 999px;\ + opacity: 0;\ + }}\ + .login-spinner.spinning {{ opacity: 1; animation: breadgreet-spin 800ms linear infinite; }}\ + \ + /* ---- session picker (styled as a surface pill) -------- */\ + .login-session {{\ + margin-top: 14px; font-size: 12px;\ + }}\ + .login-session > button {{\ + background: shade(@surface, 1.5); color: alpha(@on-surface, 0.9);\ + border: 1px solid alpha(@overlay, 0.14);\ + border-radius: 7px; padding: 9px 12px; min-height: 20px;\ + }}\ + .login-session > button:hover {{ border-color: alpha(@accent, 0.5); }}\ + .login-session > button:focus-within {{\ + border-color: @accent; box-shadow: 0 0 0 2px alpha(@accent, 0.28); outline: none;\ + }}\ + .login-session arrow {{ color: alpha(@on-surface, 0.6); min-height: 16px; min-width: 16px; }}\ + .login-session popover > contents {{\ + background: @surface; border: 1px solid alpha(@overlay, 0.12);\ + border-radius: 8px; padding: 4px;\ + }}\ + .login-session popover row {{ border-radius: 6px; padding: 6px 10px; }}\ + .login-session popover row:selected {{ background: alpha(@accent, 0.22); color: @on-surface; }}\ + \ + /* ---- keyframes --------------------------------------- */\ + @keyframes breadgreet-spin {{ to {{ transform: rotate(360deg); }} }}\ + @keyframes breadgreet-shake {{\ + 10%, 90% {{ transform: translateX(-2px); }}\ + 20%, 80% {{ transform: translateX(4px); }}\ + 30%, 50%, 70% {{ transform: translateX(-7px); }}\ + 40%, 60% {{ transform: translateX(7px); }}\ + }}", font = font, ) }