Add caps-lock chip, hold-to-reveal, idle auto-dim, and attempt counter

Four lock-screen niceties:
- Caps Lock / layout chip: a small pill above the password pill shows
  when Caps Lock is on or a non-default layout is selected, so all-caps
  input never mystifies.
- Hold-to-reveal: holding Tab renders the plain password instead of
  dots (Tab produces no utf8, so it can never be part of the password).
- Idle auto-dim: after `animation.idle_dim_after_secs` of no keystrokes
  the dim veil deepens past the base gradient (background-only alpha can
  legally exceed 1.0), ramping over a few seconds for OLED/burn-in
  comfort. Shared `veil_alpha` keeps software and GPU paths identical.
- Attempt counter: repeat failures show "Wrong password — N failed
  attempts" so a stuck locker reads differently from ordinary typos.

All four render paths covered by tests (veil math, reveal truncation,
chip/reveal compose) and live-verified in nested Hyprland on the GPU
path; idle dim confirmed to deepen the veil (84.5->75.8 top).
This commit is contained in:
Breadway 2026-08-21 15:50:44 +08:00
parent 55cf57a8ba
commit 0b378e84d0
8 changed files with 380 additions and 35 deletions

View file

@ -26,7 +26,14 @@ family = "Varela Round"
# How long the "wrong password" state (red pill) shows before input # How long the "wrong password" state (red pill) shows before input
# re-enables, in milliseconds. # re-enables, in milliseconds.
fail_timeout_ms = 800 fail_timeout_ms = 800
# Hold Tab to reveal the typed password as plain characters (instead of
# dots) while held. Tab can never be part of a password, so it's always
# safe as a reveal gesture.
reveal_hold = true
[animation] [animation]
# Subtle glow pulse on the password pill every few seconds while idle. # Subtle glow pulse on the password pill every few seconds while idle.
breathe = true breathe = true
# Deepen the dim veil after this many seconds of no keystrokes (0 = off).
# A gentle extra darkening for OLED/burn-in or late-night comfort.
idle_dim_after_secs = 0

View file

@ -53,6 +53,10 @@ struct Scene {
unlock_t: f32, unlock_t: f32,
breathe_t: f32, breathe_t: f32,
status_t: f32, status_t: f32,
caps_lock: bool,
layout_index: u32,
reveal: bool,
idle_dim: f32,
} }
impl Default for Scene { impl Default for Scene {
@ -73,6 +77,10 @@ impl Default for Scene {
unlock_t: 0.0, unlock_t: 0.0,
breathe_t: 0.0, breathe_t: 0.0,
status_t: 1.0, status_t: 1.0,
caps_lock: false,
layout_index: 0,
reveal: false,
idle_dim: 0.0,
} }
} }
} }
@ -109,8 +117,12 @@ fn bench(args: &[String]) {
font_family: FONT, font_family: FONT,
clock_text: "12:34", clock_text: "12:34",
date_text: "Friday · Aug 21", date_text: "Friday · Aug 21",
clock_old: None, clock_old: None, password_len: 6,
password_len: 6, password: "hunter2",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed: false, failed: false,
failed_t: 0.0, failed_t: 0.0,
dot_pop_t: 1.0, dot_pop_t: 1.0,
@ -153,6 +165,11 @@ fn bench(args: &[String]) {
date_text: "Friday · Aug 21", date_text: "Friday · Aug 21",
clock_old: None, clock_old: None,
password_len: 6, password_len: 6,
password: "hunter2",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed: false, failed: false,
failed_t: 0.0, failed_t: 0.0,
dot_pop_t: 1.0, dot_pop_t: 1.0,
@ -223,6 +240,16 @@ fn main() {
Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() }, Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() },
// ---- Minute rollover: old clock fading out above, new fading in below. // ---- Minute rollover: old clock fading out above, new fading in below.
Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() }, Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() },
// ---- Caps Lock on: chip above the pill.
Scene { name: "13-caps-lock", password_len: 4, caps_lock: true, ..Scene::default() },
// ---- Non-default layout: layout chip instead of caps.
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
// ---- Hold-to-reveal: plain password characters instead of dots.
Scene { name: "15-reveal", password_len: 8, reveal: true, ..Scene::default() },
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
// ---- Repeat failure: attempt counter in the status line.
Scene { name: "17-failed-3x", password_len: 6, failed: true, failed_t: 0.8, status: Some("Wrong password — 3 failed attempts"), ..Scene::default() },
]; ];
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
@ -238,6 +265,11 @@ fn main() {
date_text: scene.date, date_text: scene.date,
clock_old: scene.clock_old, clock_old: scene.clock_old,
password_len: scene.password_len, password_len: scene.password_len,
password: "hunter2",
reveal: scene.reveal,
caps_lock: scene.caps_lock,
layout_index: scene.layout_index,
idle_dim: scene.idle_dim,
failed: scene.failed, failed: scene.failed,
failed_t: scene.failed_t, failed_t: scene.failed_t,
dot_pop_t: scene.dot_pop_t, dot_pop_t: scene.dot_pop_t,

View file

@ -16,12 +16,17 @@ pub struct Config {
pub struct Input { pub struct Input {
/// How long the "wrong password" shake shows before input re-enables. /// How long the "wrong password" shake shows before input re-enables.
pub fail_timeout_ms: u64, pub fail_timeout_ms: u64,
/// Hold `Tab` to reveal the typed password as plain characters instead
/// of dots. Tab can never be part of a password (it produces no utf8),
/// so holding it is always safe to use as a reveal gesture.
pub reveal_hold: bool,
} }
impl Default for Input { impl Default for Input {
fn default() -> Self { fn default() -> Self {
Self { Self {
fail_timeout_ms: 800, fail_timeout_ms: 800,
reveal_hold: true,
} }
} }
} }
@ -35,11 +40,18 @@ pub struct Animation {
/// screen is live, not frozen. Runs only during a short active window of /// screen is live, not frozen. Runs only during a short active window of
/// each cycle (see `BREATHE_*` in render.rs). /// each cycle (see `BREATHE_*` in render.rs).
pub breathe: bool, pub breathe: bool,
/// Deepen the dim veil after this many seconds of no keystrokes (0 =
/// off). A gentle extra darkening for OLED/burn-in and late-night
/// comfort; ramps in over a few seconds once the idle threshold hits.
pub idle_dim_after_secs: u64,
} }
impl Default for Animation { impl Default for Animation {
fn default() -> Self { fn default() -> Self {
Self { breathe: true } Self {
breathe: true,
idle_dim_after_secs: 0,
}
} }
} }

View file

@ -417,7 +417,7 @@ impl GpuRenderer {
fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) {
let gl = &self.gl; let gl = &self.gl;
let (veil_alpha, _) = render::overlay_motion(inputs.appear_t, inputs.unlock_t); let veil_alpha = render::veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim);
unsafe { unsafe {
gl.use_program(Some(self.bg_program)); gl.use_program(Some(self.bg_program));
gl.bind_vertex_array(Some(self.quad_vao)); gl.bind_vertex_array(Some(self.quad_vao));

View file

@ -118,23 +118,38 @@ impl KeyboardHandler for AppState {
fn release_key( fn release_key(
&mut self, &mut self,
_conn: &Connection, _conn: &Connection,
_qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard, _keyboard: &wl_keyboard::WlKeyboard,
_serial: u32, _serial: u32,
_event: KeyEvent, event: KeyEvent,
) { ) {
// Letting go of the reveal key (Tab) drops the plain-text view back
// to dots. Any other release doesn't change state.
if event.keysym == Keysym::Tab && self.reveal_held {
self.reveal_held = false;
self.redraw_all(qh);
}
} }
fn update_modifiers( fn update_modifiers(
&mut self, &mut self,
_conn: &Connection, _conn: &Connection,
_qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard, _keyboard: &wl_keyboard::WlKeyboard,
_serial: u32, _serial: u32,
_modifiers: Modifiers, modifiers: Modifiers,
_raw_modifiers: RawModifiers, _raw_modifiers: RawModifiers,
_layout: u32, layout: u32,
) { ) {
let changed = self.caps_lock != modifiers.caps_lock || self.layout_index != layout;
self.caps_lock = modifiers.caps_lock;
self.layout_index = layout;
// A modifier update is still "activity" — it follows a key press, so
// don't let the idle auto-dim start counting while typing.
self.last_activity = Instant::now();
if changed {
self.redraw_all(qh);
}
} }
} }
@ -147,6 +162,19 @@ impl AppState {
return; return;
} }
// Any key counts as activity — it resets the idle auto-dim ramp even
// when it doesn't change the password (e.g. pressing Enter on an
// empty field).
self.last_activity = Instant::now();
// Hold-to-reveal (Tab): show the plain characters while held. Tab
// itself produces no utf8, so it can't corrupt the password.
if event.keysym == Keysym::Tab && self.config.input.reveal_hold {
self.reveal_held = true;
self.redraw_all(qh);
return;
}
match event.keysym { match event.keysym {
Keysym::Return | Keysym::KP_Enter => self.submit(), Keysym::Return | Keysym::KP_Enter => self.submit(),
Keysym::BackSpace => { Keysym::BackSpace => {

View file

@ -170,6 +170,7 @@ fn run_lock() {
// Compositor unlock() runs only after UNLOCK_MS — dying // Compositor unlock() runs only after UNLOCK_MS — dying
// mid-fade is fail-secure (session stays locked). // mid-fade is fail-secure (session stays locked).
tracing::info!("authenticated, fading out"); tracing::info!("authenticated, fading out");
state.failed_attempts = 0;
if state.unlocking.is_none() { if state.unlocking.is_none() {
state.unlocking = Some(std::time::Instant::now()); state.unlocking = Some(std::time::Instant::now());
} }
@ -191,6 +192,7 @@ fn run_lock() {
} }
auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => {
tracing::warn!(%err, "authentication failed"); tracing::warn!(%err, "authentication failed");
state.failed_attempts = state.failed_attempts.saturating_add(1);
state.auth_state = AuthState::Failed; state.auth_state = AuthState::Failed;
state.failed_at = Some(std::time::Instant::now()); state.failed_at = Some(std::time::Instant::now());
} }
@ -246,6 +248,11 @@ fn run_lock() {
+ std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS), + std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS),
), ),
anim_timer_armed: false, anim_timer_armed: false,
caps_lock: false,
layout_index: 0,
reveal_held: false,
last_activity: std::time::Instant::now(),
failed_attempts: 0,
exit: false, exit: false,
}; };

View file

@ -134,6 +134,19 @@ pub struct FrameInputs<'a> {
/// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`. /// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`.
pub clock_old: Option<(&'a str, f32)>, pub clock_old: Option<(&'a str, f32)>,
pub password_len: usize, pub password_len: usize,
/// The actual password text — only read when `reveal` is true (hold-to-
/// reveal renders the plain characters instead of dots).
pub password: &'a str,
/// True while the reveal key (Tab) is held — dots render as the plain
/// characters.
pub reveal: bool,
/// Caps Lock state — shows the caps chip when on.
pub caps_lock: bool,
/// Active keyboard layout index — shown next to the caps chip when non-0.
pub layout_index: u32,
/// Idle auto-dim progress 0..1 (0 = disabled/not idle) — deepens the dim
/// veil after `animation.idle_dim_after_secs` of no keystrokes.
pub idle_dim: f32,
/// True while showing a failed attempt (red pill + shake + red status). /// True while showing a failed attempt (red pill + shake + red status).
pub failed: bool, pub failed: bool,
/// Raw 0..1 progress of the wrong-password shake. 0 when not failed. /// Raw 0..1 progress of the wrong-password shake. 0 when not failed.
@ -222,6 +235,26 @@ pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) {
(alpha, y) (alpha, y)
} }
/// How much extra dim the idle auto-dim adds on top of the base veil at full
/// progress (`idle_dim = 1`). Both the software `dim_rows` and the GPU
/// background shader scale by this so the two paths stay identical.
pub(crate) const IDLE_DIM_MAX: f32 = 0.25;
/// How long the idle auto-dim takes to ramp from 0 to full, in milliseconds.
pub(crate) const IDLE_DIM_RAMP_MS: u64 = 8000;
/// Background dim alpha with the idle auto-dim folded in. The base veil is
/// at most 1.0 once the appear finishes, so the idle deepens *beyond* that
/// (up to `IDLE_DIM_MAX` extra) — it can legally exceed 1.0 because it only
/// scales the background dim, never a color alpha. Shared by the software
/// `dim_rows` and the GPU background shader (`u_veil_alpha`) so an
/// idle-dimmed screen looks identical on both renderers. Rides the same
/// appear/unlock envelope as the base veil so there's no residual darkening
/// as the lock releases.
pub fn veil_alpha(appear_t: f32, unlock_t: f32, idle_dim: f32) -> f32 {
let (base, _) = overlay_motion(appear_t, unlock_t);
base + IDLE_DIM_MAX * idle_dim * base
}
fn faded(mut color: Color, alpha: f32) -> Color { fn faded(mut color: Color, alpha: f32) -> Color {
color.apply_opacity(alpha); color.apply_opacity(alpha);
color color
@ -298,32 +331,36 @@ fn compose_impl(
} }
// Overall chrome fade: appear eased in, unlock eased out. The unlock // Overall chrome fade: appear eased in, unlock eased out. The unlock
// `fade` multiplies every element below. // `fade` multiplies every element below. The dim veil deepens further
// once the idle auto-dim kicks in — chrome alpha stays at the base veil
// (0..1) while only the background dim scales past it.
let unlock = ease_out_cubic(inputs.unlock_t); let unlock = ease_out_cubic(inputs.unlock_t);
let fade = 1.0 - unlock; let fade = 1.0 - unlock;
let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
if veil_alpha <= 0.0 { let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim);
if base_veil <= 0.0 {
return; return;
} }
let (w, h) = (inputs.width as f32, inputs.height as f32); let (w, h) = (inputs.width as f32, inputs.height as f32);
let surface_color = faded(tiny_skia_color(&inputs.palette.color0), veil_alpha); let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil);
let accent_color = faded(tiny_skia_color(&inputs.palette.color4), veil_alpha); let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil);
let green_color = faded(tiny_skia_color(&inputs.palette.color2), veil_alpha); let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil);
let on_surface = faded( let on_surface = faded(
tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)),
veil_alpha, base_veil,
); );
let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); let red_color = faded(tiny_skia_color(&inputs.palette.color1), base_veil);
// Translucent veil over the (static) wallpaper — a vertical gradient // Translucent veil over the (static) wallpaper — a vertical gradient
// (deeper at the top) that fades with the whole chrome. Applied in place // (deeper at the top) that fades with the whole chrome. Applied in place
// as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a // as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a
// black overlay), which is far cheaper than a full-surface gradient // black overlay), which is far cheaper than a full-surface gradient
// fill/blit every frame. Skipped in the chrome-only path (the GPU shader // fill/blit every frame. Skipped in the chrome-only path (the GPU shader
// applies the same veil to the background). // applies the same veil to the background). `bg_veil` may exceed 1.0
if rects.is_none() && veil_alpha > 0.0 { // (idle auto-dim), which is fine — it only scales a multiply.
dim_rows(pixmap, veil_alpha); if rects.is_none() && base_veil > 0.0 {
dim_rows(pixmap, bg_veil);
} }
// Per-element staggered entrance. // Per-element staggered entrance.
@ -540,15 +577,101 @@ fn compose_impl(
} }
} }
// ---- Caps Lock / layout chip: a small centered pill above the password
// pill, only when Caps Lock is on or a non-default layout is active. A
// tiny floating hint so the user can't be confused by all-caps input.
if inputs.caps_lock || inputs.layout_index > 0 {
let mut label = String::new();
if inputs.caps_lock {
label.push_str("Caps Lock");
}
if inputs.layout_index > 0 {
if !label.is_empty() {
label.push_str(" · ");
}
label.push_str(&format!("Layout {}", inputs.layout_index + 1));
}
let chip_size = tokens::FONT_SIZE_SECONDARY as f32;
let chip_w = text.measure_line(&label, inputs.font_family, chip_size) + tokens::SPACE_MD as f32 * 2.0;
let chip_h = chip_size * 1.9;
let chip_x = (w - chip_w) / 2.0;
// Clear of the pill: chip bottom sits a full SPACE_LG above the pill
// top, so the two never touch even with the pill's glow/shadow.
let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32;
let chip_alpha = pill_e * fade;
if let Some(r) = rects.as_deref_mut() {
r.expand(chip_x, chip_y, chip_x + chip_w, chip_y + chip_h);
}
if let Some(path) = rounded_rect(chip_x, chip_y, chip_w, chip_h, chip_h / 2.0) {
let mut paint = Paint::default();
// Slightly lifted surface color so it reads as a separate chip.
paint.set_color(faded(surface_color, chip_alpha));
paint.anti_alias = true;
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None);
let mut stroke = tiny_skia::Stroke::default();
stroke.width = 1.0;
let mut paint = Paint::default();
paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha));
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
let (chip_top, chip_height) = text.measure_box(&label, inputs.font_family, chip_size);
let label_y = chip_y + (chip_h - chip_height) / 2.0 - chip_top;
let label_w = text.measure_line(&label, inputs.font_family, chip_size);
text.draw_line(
&mut pixmap,
&label,
inputs.font_family,
chip_size,
faded(on_surface, chip_alpha),
(w - label_w) / 2.0,
label_y,
);
}
// ---- Password dots — one filled circle per typed character, capped so a // ---- Password dots — one filled circle per typed character, capped so a
// very long password can't overflow the pill. The newest dot pops in with // very long password can't overflow the pill. The newest dot pops in with
// an overshoot; the rest sit at rest size. // an overshoot; the rest sit at rest size. While the reveal key (Tab) is
// held, the plain characters are drawn instead.
let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / DOT_GAP) let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / DOT_GAP)
.floor() .floor()
.max(1.0) as usize; .max(1.0) as usize;
let shown_dots = inputs.password_len.min(max_dots); let shown_dots = inputs.password_len.min(max_dots);
let dot_y = pill_y + pill_h / 2.0; let dot_y = pill_y + pill_h / 2.0;
if shown_dots > 0 { if inputs.reveal && shown_dots > 0 {
// Hold-to-reveal: render the actual password, centered, capped to
// the pill width (truncate with a trailing ellipsis on overflow).
// Measured into locals first — `text` is borrowed mutably by
// `draw_line`, so all `measure_*` calls must happen up front.
let reveal_size = tokens::FONT_SIZE_BASE as f32;
let reveal_rendered = reveal_fit(
text,
inputs.password,
inputs.font_family,
reveal_size,
pill_w - tokens::SPACE_LG as f32 * 2.0,
);
let (reveal_top, reveal_height) =
text.measure_box(&reveal_rendered, inputs.font_family, reveal_size);
let reveal_y = pill_y + (pill_h - reveal_height) / 2.0 - reveal_top;
let reveal_w = text.measure_line(&reveal_rendered, inputs.font_family, reveal_size);
if let Some(r) = rects.as_deref_mut() {
r.expand(
pill_x + tokens::SPACE_LG as f32,
pill_y,
pill_x + pill_w - tokens::SPACE_LG as f32,
pill_y + pill_h,
);
}
text.draw_line(
&mut pixmap,
&reveal_rendered,
inputs.font_family,
reveal_size,
faded(on_surface, pill_alpha),
(w - reveal_w) / 2.0,
reveal_y,
);
} else if shown_dots > 0 {
let start_x = start_x_for(shown_dots, pill_x, pill_w); let start_x = start_x_for(shown_dots, pill_x, pill_w);
for i in 0..shown_dots { for i in 0..shown_dots {
let newest = i == shown_dots - 1; let newest = i == shown_dots - 1;
@ -678,6 +801,34 @@ fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 {
pill_x + (pill_w - dots_w) / 2.0 pill_x + (pill_w - dots_w) / 2.0
} }
/// Truncates a password for the hold-to-reveal view so it fits inside the
/// pill, appending an ellipsis when trimmed. Returns the owned string to
/// render (kept out of `compose` so the borrow of `text` ends before the
/// draw call).
fn reveal_fit(
text: &mut TextRenderer,
password: &str,
font_family: &str,
size: f32,
max_w: f32,
) -> String {
let mut shown = password;
let mut ellipsis = "";
loop {
let candidate = format!("{shown}{ellipsis}");
if text.measure_line(&candidate, font_family, size) <= max_w || shown.is_empty() {
return candidate;
}
// Trim one char at a time until it fits.
shown = &shown[..shown
.char_indices()
.nth_back(1)
.map(|(i, _)| i)
.unwrap_or(0)];
ellipsis = "";
}
}
/// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling /// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling
/// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm` /// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm`
/// expects (BGRA bytes on little-endian, which is every target this ships /// expects (BGRA bytes on little-endian, which is every target this ships
@ -744,6 +895,11 @@ mod tests {
date_text: date, date_text: date,
clock_old: None, clock_old: None,
password_len, password_len,
password: "",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed, failed,
failed_t, failed_t,
dot_pop_t, dot_pop_t,
@ -782,6 +938,11 @@ mod tests {
date_text: "Friday · Aug 21", date_text: "Friday · Aug 21",
clock_old: None, clock_old: None,
password_len: 0, password_len: 0,
password: "",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed: false, failed: false,
failed_t: 0.0, failed_t: 0.0,
dot_pop_t: 1.0, dot_pop_t: 1.0,
@ -814,6 +975,59 @@ mod tests {
assert!(compose(&mut text, &done).is_some()); assert!(compose(&mut text, &done).is_some());
} }
#[test]
fn veil_alpha_idle_dim_deepens_past_base() {
// Rest pose, no idle: base appear alpha only (1.0).
assert_eq!(veil_alpha(1.0, 0.0, 0.0), 1.0);
// Mid-appear, no idle: base alpha.
let base = veil_alpha(0.5, 0.0, 0.0);
assert!(base > 0.0 && base < 1.0);
// Full idle dim deepens *past* the base (background-only alpha, so
// exceeding 1.0 is legal — it scales the dim multiply, not a color).
let idle = veil_alpha(0.5, 0.0, 1.0);
assert!(idle > base, "idle dim should deepen the veil");
// At rest with full idle: 1.0 + 0.25 * 1.0.
assert!((veil_alpha(1.0, 0.0, 1.0) - 1.25).abs() < 1e-6);
// Idle dim alone can't darken a screen that hasn't appeared yet
// (base 0 keeps the whole term 0).
assert_eq!(veil_alpha(0.0, 0.0, 1.0), 0.0);
// During unlock, idle dim can't push past the fade-out.
assert_eq!(veil_alpha(1.0, 1.0, 1.0), 0.0);
}
#[test]
fn reveal_fit_truncates_long_passwords() {
let mut text = TextRenderer::new();
// Short password fits unchanged.
assert_eq!(reveal_fit(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2");
// A very long one is trimmed and ends with an ellipsis.
let long = "a".repeat(200);
let fitted = reveal_fit(&mut text, &long, "sans-serif", 14.0, 60.0);
assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis");
assert!(fitted.len() < long.len());
// And it actually fits the budget.
assert!(text.measure_line(&fitted, "sans-serif", 14.0) <= 60.0);
}
#[test]
fn compose_renders_caps_chip_and_reveal() {
let bg = Background::Color(Color::BLACK);
let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new();
let mut base = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
base.caps_lock = true;
base.password = "hunter2";
// Caps chip visible, no reveal: dots path.
assert!(compose(&mut text, &base).is_some());
// Reveal: plain characters instead of dots.
base.reveal = true;
assert!(compose(&mut text, &base).is_some());
// Non-default layout shows the layout chip too.
base.caps_lock = false;
base.layout_index = 1;
assert!(compose(&mut text, &base).is_some());
}
#[test] #[test]
fn ease_out_cubic_bounds_and_shape() { fn ease_out_cubic_bounds_and_shape() {
assert_eq!(ease_out_cubic(0.0), 0.0); assert_eq!(ease_out_cubic(0.0), 0.0);

View file

@ -119,6 +119,23 @@ pub struct AppState {
/// True while a ~16ms animation timer is registered on the event loop. /// True while a ~16ms animation timer is registered on the event loop.
pub anim_timer_armed: bool, pub anim_timer_armed: bool,
/// Caps Lock is on (from the last keyboard modifier update) — drives the
/// small "Caps Lock" chip so the user isn't mystified by uppercase-only
/// input. Stale until the first modifier update arrives.
pub caps_lock: bool,
/// Active keyboard layout index (0-based) — shown next to the caps chip
/// when a non-default layout is selected.
pub layout_index: u32,
/// True while the user holds the reveal key (Tab) — dots render as the
/// plain characters while held.
pub reveal_held: bool,
/// Last keystroke/activity timestamp — drives the idle auto-dim ramp
/// (`animation.idle_dim_after_secs`). Any key press resets it.
pub last_activity: Instant,
/// Consecutive failed password attempts this session — drives the
/// "N failed attempts" status line. Reset on a successful auth.
pub failed_attempts: u32,
pub exit: bool, pub exit: bool,
} }
@ -172,13 +189,36 @@ impl AppState {
// While a PAM check runs, the status dots tick to signal progress. // While a PAM check runs, the status dots tick to signal progress.
let status_text = match self.auth_state { let status_text = match self.auth_state {
AuthState::Checking => Some(format!("Checking{}", checking_dots(now))), AuthState::Checking => Some(format!("Checking{}", checking_dots(now))),
AuthState::Failed => Some("Wrong password".to_string()), AuthState::Failed => {
// Repeat failures get a counter so the user can tell the
// locker apart from a stuck/corrupt one ("Wrong password"
// alone reads identically every time).
let n = self.failed_attempts.max(1);
Some(if n > 1 {
format!("Wrong password — {n} failed attempts")
} else {
"Wrong password".to_string()
})
}
AuthState::ConfigError => Some( AuthState::ConfigError => Some(
"PAM config error — check logs (breadlock service not set up correctly)" "PAM config error — check logs (breadlock service not set up correctly)"
.to_string(), .to_string(),
), ),
AuthState::Idle => None, AuthState::Idle => None,
}; };
// Idle auto-dim: ramp 0..1 over IDLE_DIM_RAMP_MS once the configured
// idle threshold elapses with no keystrokes. 0 when disabled.
let idle_dim = if self.config.animation.idle_dim_after_secs > 0 {
let idle_s = self.last_activity.elapsed().as_secs_f64()
- self.config.animation.idle_dim_after_secs as f64;
if idle_s <= 0.0 {
0.0
} else {
(idle_s / (render::IDLE_DIM_RAMP_MS as f64 / 1000.0)).min(1.0) as f32
}
} else {
0.0
};
let status_t = self let status_t = self
.status_anim_started .status_anim_started
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS)) .map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
@ -224,6 +264,11 @@ impl AppState {
date_text: &date_text, date_text: &date_text,
clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)),
password_len: self.password.len(), password_len: self.password.len(),
password: &self.password,
reveal: self.reveal_held,
caps_lock: self.caps_lock,
layout_index: self.layout_index,
idle_dim,
failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError), failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError),
failed_t, failed_t,
dot_pop_t, dot_pop_t,