breadgreet: skip the username step, fix clobbered typography
Some checks failed
CI / check (pull_request) Failing after 5s

Login UX
- Enumerate human accounts from /etc/passwd (UID_MIN..UID_MAX per
  /etc/login.defs, real login shell, minus nobody) and skip the
  username field: one account goes straight to the password prompt
  with the name shown, several get a picker. Zero found falls back to
  typing. New [user] config: `name` pins one account, `prompt = true`
  restores the type-it flow.
- The greeter opens the greetd conversation at startup in auto mode so
  it lands directly on the password prompt (AutoStart).
- reset_to_username -> reset_auth: auto mode re-opens the password
  prompt for the same account on error/Escape rather than showing a
  username field it never had.

Typography fix
- bind_window_auto re-broadcasts the shared component sheet (incl. its
  `* { font-size }` base rule) at USER-10, which outranks the
  APPLICATION-priority provider apply_app_css uses regardless of
  selector specificity -- so the hero clock and every other type
  override silently collapsed to base size. Ride breadgreet's sheet
  through bind_window_auto_with_app_css instead (USER-9), matching
  breadbar.

Polish
- Accent rule between the clock and card, drawn in via scaleX with a
  glow halo; card gradient + top highlight + deep shadow, bg-pop
  overshoot entrance, slow bg-breathe idle pulse; layered accent focus
  glow on the entry; gradient session/user pills. Entrance/idle motion
  is CSS @keyframes now (setup_entrance removed).
- Errors pinned to a legible red -- BOS's default @red slot is a warm
  ochre, unreadable as a warning.
- Drop the duplicate prompt text (placeholder + status line both said
  "Password:").

Preview harness
- scripts/preview.sh + scripts/mock-greetd.py run the real greeter in a
  nested Weston window against a stand-in greetd, so the whole flow
  (spinner, wrong-password shake, success) is drivable without a
  reboot. $BREADGREET_CONFIG overrides the config search path so a
  preview never touches /etc/greetd/breadgreet.toml.
This commit is contained in:
Breadway 2026-08-31 21:23:06 +08:00
parent 7ac3fdfc85
commit 8232f1b72b
7 changed files with 770 additions and 114 deletions

View file

@ -2,6 +2,7 @@ mod config;
mod greetd;
mod sessions;
mod theme;
mod users;
use greetd::{AuthPrompt, Outcome};
use gtk4::gdk::Key;
@ -15,13 +16,15 @@ const KENBURNS_ZOOM: f32 = 1.06;
#[derive(Debug, Clone)]
enum Stage {
/// Waiting for a username in `entry`.
/// Waiting for a username typed into `entry` — only reached when user
/// enumeration is off or found nothing (see [`App::typed_mode`]).
Username,
/// greetd/PAM asked a question; `entry` holds the answer (masking is
/// applied imperatively on the entry widget when the prompt arrives).
Prompt,
/// A request is in flight — input is disabled so a second Enter can't
/// race it.
/// race it. Also the initial stage in auto-user mode, while the opening
/// `CreateSession` is in flight.
Working,
/// `StartSession` has been sent — Escape must not cancel.
Starting,
@ -37,6 +40,11 @@ enum AppInput {
SessionStarted,
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
SessionSelected(u32),
/// User picker changed (multi-user auto mode) — restart auth for that user.
UserSelected(u32),
/// Post-init kick in auto-user mode: open the greetd conversation for the
/// resolved user so the greeter lands straight on the password prompt.
AutoStart,
/// Escape — abort the in-progress PAM conversation.
Cancel,
}
@ -49,7 +57,13 @@ struct App {
card: gtk4::Box,
spinner: gtk4::Box,
stage: Stage,
/// The name currently being authenticated — typed in [`Stage::Username`],
/// or the resolved account in auto-user mode.
username: String,
/// Enumerated login accounts. Empty ⇒ typed-username mode; one ⇒ straight
/// to the password prompt; several ⇒ `user_idx` selects among them.
users: Vec<users::User>,
user_idx: usize,
sessions: Vec<sessions::Session>,
selected: usize,
clock_format: String,
@ -109,6 +123,24 @@ impl SimpleComponent for App {
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
.unwrap_or(0);
// Who's logging in. `[user] prompt` keeps the old type-it flow; a set
// `[user] name` pins one account; otherwise enumerate `/etc/passwd`.
// A non-empty list means "auto mode": no username field, straight to
// the password prompt (with a picker if there's more than one).
let users = if config.user.prompt {
Vec::new()
} else if !config.user.name.trim().is_empty() {
let name = config.user.name.trim().to_string();
vec![users::User {
display: name.clone(),
name,
uid: 0,
}]
} else {
users::list()
};
let auto_user = !users.is_empty();
if config.appearance.background.blur {
tracing::warn!(
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
@ -121,19 +153,33 @@ impl SimpleComponent for App {
let date_lbl = gtk4::Label::new(None);
date_lbl.add_css_class("login-date");
// Spacing below the clock cluster is the accent rule's own top/bottom
// margin (see `.login-rule` CSS); only a hair of clock→date gap here.
if config.appearance.clock.date_format.is_empty() {
date_lbl.set_visible(false);
clock_lbl.set_margin_bottom(20);
} else {
clock_lbl.set_margin_bottom(4);
date_lbl.set_margin_bottom(16);
date_lbl.set_label(&current_time(&config.appearance.clock.date_format));
}
let entry = gtk4::Entry::new();
entry.add_css_class("login-entry");
entry.set_placeholder_text(Some("Username"));
entry.set_width_chars(24);
// A leading glyph that swaps person → key when PAM asks for a secret
// (kept in sync in show_auth_entry / reset_auth). In auto-user mode the
// entry is only ever the password field, and starts disabled until the
// opening `CreateSession` produces a prompt.
if auto_user {
entry.set_placeholder_text(Some("Password"));
entry.set_primary_icon_name(Some("dialog-password-symbolic"));
entry.set_visibility(false);
entry.set_sensitive(false);
} else {
entry.set_placeholder_text(Some("Username"));
entry.set_primary_icon_name(Some("avatar-default-symbolic"));
}
entry.set_primary_icon_activatable(false);
entry.set_primary_icon_sensitive(false);
{
let sender = sender.clone();
entry.connect_activate(move |_| sender.input(AppInput::Submit));
@ -155,7 +201,6 @@ impl SimpleComponent for App {
} else {
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
let dropdown = gtk4::DropDown::from_strings(&names);
dropdown.add_css_class("login-session");
dropdown.set_hexpand(true);
dropdown.set_focusable(true);
dropdown.set_tooltip_text(Some("Session"));
@ -167,7 +212,55 @@ impl SimpleComponent for App {
sender.input(AppInput::SessionSelected(dd.selected()));
});
}
dropdown.upcast()
// Wrap the dropdown in the design sketch's `.srow`: a surface pill
// with a gradient session glyph on the left (drawn purely in CSS).
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.add_css_class("login-session");
row.add_css_class("session-row");
let glyph = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
glyph.add_css_class("session-icon");
glyph.set_valign(gtk4::Align::Center);
row.append(&glyph);
row.append(&dropdown);
row.upcast()
};
// The "who's logging in" row, shown above the password field in auto
// mode: a gradient avatar glyph + either the account name (one user) or
// a picker (several). Nothing in typed mode — the entry is the field.
let user_widget: Option<gtk4::Widget> = if !auto_user {
None
} else {
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.add_css_class("login-user");
row.add_css_class("user-row");
let glyph = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
glyph.add_css_class("user-icon");
glyph.set_valign(gtk4::Align::Center);
row.append(&glyph);
if users.len() == 1 {
let name = gtk4::Label::new(Some(&users[0].display));
name.add_css_class("user-name");
name.set_halign(gtk4::Align::Start);
name.set_hexpand(true);
name.set_ellipsize(gtk4::pango::EllipsizeMode::End);
row.append(&name);
} else {
let labels: Vec<&str> = users.iter().map(|u| u.display.as_str()).collect();
let dropdown = gtk4::DropDown::from_strings(&labels);
dropdown.set_hexpand(true);
dropdown.set_focusable(true);
dropdown.set_tooltip_text(Some("User"));
dropdown.update_property(&[gtk4::accessible::Property::Label("User")]);
{
let sender = sender.clone();
dropdown.connect_selected_notify(move |dd| {
sender.input(AppInput::UserSelected(dd.selected()));
});
}
row.append(&dropdown);
}
Some(row.upcast())
};
// A CSS-animated ring (not GtkSpinner) so it matches the design
@ -179,6 +272,9 @@ impl SimpleComponent for App {
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
card.add_css_class("login-card");
if let Some(w) = &user_widget {
card.append(w);
}
card.append(&entry);
card.append(&status_lbl);
card.append(&spinner);
@ -208,8 +304,16 @@ impl SimpleComponent for App {
widgets.overlay.add_overlay(&veil);
widgets.overlay.add_overlay(&widgets.root_box);
// Thin accent rule between the clock cluster and the card — a
// signature detail shared with the lock screen; it draws itself in
// (scaleX 0→1) via `.login-rule`.
let rule = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
rule.add_css_class("login-rule");
rule.set_halign(gtk4::Align::Center);
widgets.root_box.append(&clock_lbl);
widgets.root_box.append(&date_lbl);
widgets.root_box.append(&rule);
widgets.root_box.append(&card);
{
@ -239,15 +343,18 @@ impl SimpleComponent for App {
None
};
setup_wallpaper(&root, &bg_area, wallpaper_path.as_deref(), ken_burns);
setup_entrance(&root, &widgets.root_box);
// Entrance motion (clock rise, rule draw, card pop-in + idle breathe)
// is CSS `@keyframes` in theme.rs — GTK4 plays `animation` when a
// widget's style is first computed on show.
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
spawn_greetd_actor(cmd_rx, sender.clone());
theme::apply(&config.appearance.font.family);
bread_theme::gtk::bind_window_auto(&root);
theme::bind(&root, &config.appearance.font.family);
spawn_clock_ticker(sender.clone());
let username = users.first().map(|u| u.name.clone()).unwrap_or_default();
let model = App {
clock_lbl,
date_lbl,
@ -255,8 +362,16 @@ impl SimpleComponent for App {
entry,
card,
spinner,
stage: Stage::Username,
username: String::new(),
// Auto mode opens in `Working` — the greeter is already waiting on
// greetd for the first prompt (kicked by `AutoStart` below).
stage: if auto_user {
Stage::Working
} else {
Stage::Username
},
username,
users,
user_idx: 0,
sessions,
selected,
clock_format: config.appearance.clock.format.clone(),
@ -267,7 +382,9 @@ impl SimpleComponent for App {
model
.clock_lbl
.set_label(&current_time(&model.clock_format));
if !model.sessions.is_empty() {
if auto_user {
sender.input(AppInput::AutoStart);
} else if !model.sessions.is_empty() {
model.entry.grab_focus();
}
@ -299,6 +416,16 @@ impl SimpleComponent for App {
self.selected = idx;
}
}
AppInput::UserSelected(idx) => self.switch_user(idx as usize),
AppInput::AutoStart => {
if self.sessions.is_empty() {
// Nothing to log into — leave the "no session" error up.
self.stage = Stage::Username;
} else {
self.status_lbl.set_label("");
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
}
}
AppInput::Cancel => self.cancel_auth(),
}
self.sync_busy();
@ -388,13 +515,19 @@ impl App {
}
fn show_auth_entry(&mut self, message: &str, visible: bool) {
// The prompt text goes in the placeholder; the status line stays for
// Info/Error messages only (a preceding one is kept via
// `pam_status_held`, otherwise it's cleared — no echoing "Password:"
// both in the field and under it).
if !self.pam_status_held {
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label(message);
self.status_lbl.set_label("");
}
self.pam_status_held = false;
self.entry.set_visibility(visible);
self.entry.set_placeholder_text(Some(message));
self.entry
.set_primary_icon_name(Some("dialog-password-symbolic"));
self.entry.set_sensitive(true);
self.entry.grab_focus();
self.stage = Stage::Prompt;
@ -416,6 +549,31 @@ impl App {
self.dispatch(greetd::Command::StartSession { cmd, env });
}
/// No account resolved up front — the greeter asks for the username.
fn typed_mode(&self) -> bool {
self.users.is_empty()
}
/// Multi-user auto mode: the picker changed. Tear down the current greetd
/// conversation and open a fresh one for the newly selected account.
fn switch_user(&mut self, idx: usize) {
if matches!(self.stage, Stage::Starting) || idx >= self.users.len() || idx == self.user_idx
{
return;
}
self.user_idx = idx;
self.username = self.users[idx].name.clone();
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
self.pam_status_held = false;
self.entry.set_text("");
self.entry.set_sensitive(false);
self.entry.set_placeholder_text(Some("Password"));
self.stage = Stage::Working;
let _ = self.cmd_tx.send(greetd::Command::CancelSession);
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
}
fn cancel_auth(&mut self) {
match self.stage {
Stage::Starting => {}
@ -425,9 +583,9 @@ impl App {
Stage::Prompt | Stage::Working => {
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
// `reset_to_username` dispatches the CancelSession itself, so
// we don't double-send it here.
self.reset_to_username();
// `reset_auth` dispatches the CancelSession itself, so we don't
// double-send it here.
self.reset_auth();
}
}
}
@ -446,30 +604,53 @@ impl App {
self.status_lbl.add_css_class("error");
self.flash_error();
}
self.reset_to_username();
self.reset_auth();
}
fn reset_to_username(&mut self) {
/// Return to the start of the auth flow after an error or an Escape.
///
/// Typed mode goes back to the username field; auto mode re-opens the
/// password prompt for the same account (there's no username step to
/// return to). Either way the stale greetd conversation is cancelled
/// first, so the next `CreateSession` doesn't stack on a half-done one.
fn reset_auth(&mut self) {
self.entry.set_text("");
self.entry.set_visibility(true);
self.entry.set_placeholder_text(Some("Username"));
self.entry.set_sensitive(!self.sessions.is_empty());
self.stage = Stage::Username;
self.username.clear();
self.pam_status_held = false;
// Abort any greetd conversation still open server-side. Without this,
// the error/`show_error` reset path returns to the username entry but
// leaves greetd holding a half-done PAM conversation, so the next
// login attempt's CreateSession stacks on a stale session. On a
// broken channel we set the failure label directly rather than
// recursing into `show_error`, which would call back into
// `reset_to_username` forever.
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
// On a broken channel set the failure label directly rather than
// recursing through `show_error` (which calls back here forever).
let channel_ok = self.cmd_tx.send(greetd::Command::CancelSession).is_ok();
if !channel_ok {
self.status_lbl.set_label("Cannot reach greetd");
self.status_lbl.add_css_class("error");
}
if !self.sessions.is_empty() {
self.entry.grab_focus();
if self.typed_mode() {
self.entry.set_placeholder_text(Some("Username"));
self.entry
.set_primary_icon_name(Some("avatar-default-symbolic"));
self.entry.set_sensitive(!self.sessions.is_empty());
self.stage = Stage::Username;
self.username.clear();
if !self.sessions.is_empty() {
self.entry.grab_focus();
}
} else {
// Auto mode: straight back to a fresh password prompt. The entry
// stays disabled until the reopened conversation's Secret prompt
// arrives (see `show_auth_entry`).
self.entry.set_visibility(false);
self.entry.set_placeholder_text(Some("Password"));
self.entry
.set_primary_icon_name(Some("dialog-password-symbolic"));
self.entry.set_sensitive(false);
self.stage = Stage::Working;
if channel_ok && !self.sessions.is_empty() {
let _ = self
.cmd_tx
.send(greetd::Command::CreateSession(self.username.clone()));
}
}
}
}
@ -553,31 +734,6 @@ fn setup_wallpaper(
});
}
/// Entrance animation: the clock + card cluster fades in and rises ~24px
/// over ~600ms (ease-out), matching the lock screen's appear motion.
fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) {
let root_box = root_box.clone();
const DURATION_MS: f32 = 600.0;
const RISE_PX: f32 = 24.0;
// First mapped frame must not be fully opaque — start hidden, then tick.
root_box.set_opacity(0.0);
root_box.set_margin_top(RISE_PX as i32);
let start = std::time::Instant::now();
window.add_tick_callback(move |_w, _frame_clock| {
let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS;
let t = t.clamp(0.0, 1.0);
// Ease-out cubic.
let e = 1.0 - (1.0 - t).powi(3);
root_box.set_opacity(e as f64);
root_box.set_margin_top((RISE_PX * (1.0 - e)) as i32);
if t >= 1.0 {
gtk4::glib::ControlFlow::Break
} else {
gtk4::glib::ControlFlow::Continue
}
});
}
/// Owns the single stateful connection to `$GREETD_SOCK` and translates the
/// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each
/// outcome back as an [`AppInput`].