From 0b378e84d09bc44a21284a0ffcf4d5b567c1d6f3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 15:50:44 +0800 Subject: [PATCH] Add caps-lock chip, hold-to-reveal, idle auto-dim, and attempt counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- breadlock.example.toml | 7 + breadlock/src/bin/breadlock-preview.rs | 60 +++++-- breadlock/src/config.rs | 14 +- breadlock/src/gpu.rs | 2 +- breadlock/src/input/keyboard.rs | 38 +++- breadlock/src/main.rs | 7 + breadlock/src/render.rs | 240 +++++++++++++++++++++++-- breadlock/src/state.rs | 47 ++++- 8 files changed, 380 insertions(+), 35 deletions(-) diff --git a/breadlock.example.toml b/breadlock.example.toml index e9e53e6..188090a 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -26,7 +26,14 @@ family = "Varela Round" # How long the "wrong password" state (red pill) shows before input # re-enables, in milliseconds. 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] # Subtle glow pulse on the password pill every few seconds while idle. 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 diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index 3fb14fd..f9edd65 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -53,6 +53,10 @@ struct Scene { unlock_t: f32, breathe_t: f32, status_t: f32, + caps_lock: bool, + layout_index: u32, + reveal: bool, + idle_dim: f32, } impl Default for Scene { @@ -73,6 +77,10 @@ impl Default for Scene { unlock_t: 0.0, breathe_t: 0.0, status_t: 1.0, + caps_lock: false, + layout_index: 0, + reveal: false, + idle_dim: 0.0, } } } @@ -109,20 +117,24 @@ fn bench(args: &[String]) { font_family: FONT, clock_text: "12:34", date_text: "Friday · Aug 21", - clock_old: None, - password_len: 6, - failed: false, - failed_t: 0.0, - dot_pop_t: 1.0, - keystroke_age: None, - t_secs: 0.0, - breathe_t: 0.0, - status_t: 1.0, - status_text: None, - appear_t: 1.0, - unlock_t: 0.0, - smooth_pan: true, - }; + clock_old: None, password_len: 6, + password: "hunter2", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, + failed: false, + failed_t: 0.0, + dot_pop_t: 1.0, + keystroke_age: None, + t_secs: 0.0, + breathe_t: 0.0, + status_t: 1.0, + status_text: None, + appear_t: 1.0, + unlock_t: 0.0, + smooth_pan: true, + }; compose(&mut text, &warm).expect("warm-up compose failed"); // Isolate the background pass cost (wallpaper blit + fills) alone. @@ -153,6 +165,11 @@ fn bench(args: &[String]) { date_text: "Friday · Aug 21", clock_old: None, password_len: 6, + password: "hunter2", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, failed: false, failed_t: 0.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() }, // ---- 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() }, + // ---- 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(); @@ -238,6 +265,11 @@ fn main() { date_text: scene.date, clock_old: scene.clock_old, 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_t: scene.failed_t, dot_pop_t: scene.dot_pop_t, diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index 4cf6252..cb36ce0 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -16,12 +16,17 @@ pub struct Config { pub struct Input { /// How long the "wrong password" shake shows before input re-enables. 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 { fn default() -> Self { Self { 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 /// each cycle (see `BREATHE_*` in render.rs). 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 { fn default() -> Self { - Self { breathe: true } + Self { + breathe: true, + idle_dim_after_secs: 0, + } } } diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index 6d668ef..596c804 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -417,7 +417,7 @@ impl GpuRenderer { fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { 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 { gl.use_program(Some(self.bg_program)); gl.bind_vertex_array(Some(self.quad_vao)); diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index fb3c10f..07d474c 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -118,23 +118,38 @@ impl KeyboardHandler for AppState { fn release_key( &mut self, _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, _keyboard: &wl_keyboard::WlKeyboard, _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( &mut self, _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, _keyboard: &wl_keyboard::WlKeyboard, _serial: u32, - _modifiers: Modifiers, + modifiers: Modifiers, _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; } + // 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 { Keysym::Return | Keysym::KP_Enter => self.submit(), Keysym::BackSpace => { diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 4e43ea6..1d57862 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -170,6 +170,7 @@ fn run_lock() { // Compositor unlock() runs only after UNLOCK_MS — dying // mid-fade is fail-secure (session stays locked). tracing::info!("authenticated, fading out"); + state.failed_attempts = 0; if state.unlocking.is_none() { state.unlocking = Some(std::time::Instant::now()); } @@ -191,6 +192,7 @@ fn run_lock() { } auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { tracing::warn!(%err, "authentication failed"); + state.failed_attempts = state.failed_attempts.saturating_add(1); state.auth_state = AuthState::Failed; 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), ), anim_timer_armed: false, + caps_lock: false, + layout_index: 0, + reveal_held: false, + last_activity: std::time::Instant::now(), + failed_attempts: 0, exit: false, }; diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 273814e..21b0e65 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -134,6 +134,19 @@ pub struct FrameInputs<'a> { /// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`. pub clock_old: Option<(&'a str, f32)>, 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). pub failed: bool, /// 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) } +/// 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 { color.apply_opacity(alpha); color @@ -298,32 +331,36 @@ fn compose_impl( } // 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 fade = 1.0 - unlock; - let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); - if veil_alpha <= 0.0 { + let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); + let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim); + if base_veil <= 0.0 { return; } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), veil_alpha); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), veil_alpha); - let green_color = faded(tiny_skia_color(&inputs.palette.color2), veil_alpha); + let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil); + let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil); + let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil); let on_surface = faded( 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 // (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 // black overlay), which is far cheaper than a full-surface gradient // fill/blit every frame. Skipped in the chrome-only path (the GPU shader - // applies the same veil to the background). - if rects.is_none() && veil_alpha > 0.0 { - dim_rows(pixmap, veil_alpha); + // applies the same veil to the background). `bg_veil` may exceed 1.0 + // (idle auto-dim), which is fine — it only scales a multiply. + if rects.is_none() && base_veil > 0.0 { + dim_rows(pixmap, bg_veil); } // 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 // 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) .floor() .max(1.0) as usize; let shown_dots = inputs.password_len.min(max_dots); 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); for i in 0..shown_dots { 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 } +/// 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 /// 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 @@ -744,6 +895,11 @@ mod tests { date_text: date, clock_old: None, password_len, + password: "", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, failed, failed_t, dot_pop_t, @@ -782,6 +938,11 @@ mod tests { date_text: "Friday · Aug 21", clock_old: None, password_len: 0, + password: "", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, failed: false, failed_t: 0.0, dot_pop_t: 1.0, @@ -814,6 +975,59 @@ mod tests { 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] fn ease_out_cubic_bounds_and_shape() { assert_eq!(ease_out_cubic(0.0), 0.0); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 4a98690..c6ade48 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -119,6 +119,23 @@ pub struct AppState { /// True while a ~16ms animation timer is registered on the event loop. 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, } @@ -172,13 +189,36 @@ impl AppState { // While a PAM check runs, the status dots tick to signal progress. let status_text = match self.auth_state { 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( "PAM config error — check logs (breadlock service not set up correctly)" .to_string(), ), 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 .status_anim_started .map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS)) @@ -224,6 +264,11 @@ impl AppState { date_text: &date_text, clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), 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_t, dot_pop_t,