breadgreet: skip the username step, fix clobbered typography
Some checks failed
CI / check (pull_request) Failing after 5s
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:
parent
7ac3fdfc85
commit
8232f1b72b
7 changed files with 770 additions and 114 deletions
|
|
@ -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(¤t_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(¤t_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: >k4::ApplicationWindow, root_box: >k4::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`].
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue