diff --git a/.gitignore b/.gitignore index ec10cb0..2baf113 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ CLAUDE.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# breadlock-preview PNG output (dev-only animation harness) +preview/ diff --git a/breadgreet.example.toml b/breadgreet.example.toml index ae0af95..150d830 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -9,9 +9,13 @@ mode = "color" path = "" blur = false +# (Ken Burns is a breadlock-only background effect; not used by the greeter.) +ken_burns = false [clock] format = "%H:%M" +# strftime format for the date line under the clock; empty string hides it +date_format = "%A · %b %d" [font] family = "Varela Round" diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs index 2747769..179a5f7 100644 --- a/breadlock-ui/src/config.rs +++ b/breadlock-ui/src/config.rs @@ -26,6 +26,10 @@ pub struct Background { /// v2 feature flag — no-op (with a warning) in v1, which only supports a /// static color or image background. pub blur: bool, + /// Slow Ken Burns pan on image backgrounds (a gentle drift + zoom instead + /// of a static image). CPU cost: the background redraws continuously at a + /// low frame rate while locked, so this is opt-in. + pub ken_burns: bool, } impl Default for Background { @@ -34,6 +38,7 @@ impl Default for Background { mode: BackgroundMode::Color, path: String::new(), blur: false, + ken_burns: false, } } } @@ -42,12 +47,16 @@ impl Default for Background { #[serde(default)] pub struct Clock { pub format: String, + /// strftime format for the date line under the clock. Empty string hides + /// the date. `%A` = full weekday, `%b` = abbreviated month, `%d` = day. + pub date_format: String, } impl Default for Clock { fn default() -> Self { Self { format: "%H:%M".to_string(), + date_format: "%A · %b %d".to_string(), } } } @@ -89,7 +98,9 @@ mod tests { fn defaults_match_design_system() { let a = Appearance::default(); assert_eq!(a.background.mode, BackgroundMode::Color); + assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)"); assert_eq!(a.clock.format, "%H:%M"); + assert_eq!(a.clock.date_format, "%A · %b %d"); assert_eq!(a.font.family, "Varela Round"); } diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index d383058..b84f471 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -6,6 +6,7 @@ pub use bread_theme::tokens; use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache}; +use std::collections::HashMap; use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8}; /// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds @@ -32,6 +33,10 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option pub struct TextRenderer { font_system: FontSystem, swash_cache: SwashCache, + /// Exact glyph-pixel span `(top, height)` per unique `(text, family, + /// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so + /// fractional sizes don't thrash the cache. + boxes: HashMap<(String, String, u32), (f32, f32)>, } impl Default for TextRenderer { @@ -45,6 +50,7 @@ impl TextRenderer { Self { font_system: FontSystem::new(), swash_cache: SwashCache::new(), + boxes: HashMap::new(), } } @@ -58,6 +64,47 @@ impl TextRenderer { buffer } + /// Exact vertical span `(top, height)` of the glyph pixels a line drawn + /// with [`Self::draw_line`] at `(0, 0)` would occupy: `top` is the + /// distance from the draw origin down to the highest glyph pixel. + /// `draw_line`'s `origin_y` anchors the *top* of the text (not the + /// baseline), so centering a line of height `h` in a box spanning + /// `[y0, y1]` needs `origin_y = y0 + (h - height) / 2 - top`. + /// + /// Measured exactly by rendering the line once into a tiny offscreen + /// pixmap and scanning it, then cached — lock-screen text changes rarely + /// (clock per minute, date per day, static hints once), so the one-off + /// cost is negligible and the result is correct for any font. + pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) { + let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32); + if let Some(b) = self.boxes.get(&key) { + return *b; + } + let w = self.measure_line(text, family, size_px).ceil().max(1.0) as u32; + let h = (size_px * 1.5).ceil().max(1.0) as u32; + let mut probe = match Pixmap::new(w, h) { + Some(p) => p, + None => return (0.0, size_px), + }; + self.draw_line(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0); + let (mut top, mut bottom) = (h as f32, 0.0f32); + for y in 0..h { + for x in 0..w { + if probe.pixel(x, y).is_some_and(|p| p.alpha() > 0) { + top = top.min(y as f32); + bottom = bottom.max(y as f32); + } + } + } + let boxed = if bottom >= top { + (top, bottom - top + 1.0) + } else { + (0.0, size_px) + }; + self.boxes.insert(key, boxed); + boxed + } + /// Width in pixels `text` would occupy if drawn via [`Self::draw_line`] /// with the same `family`/`size_px` — use to center text before drawing. pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 { @@ -85,7 +132,13 @@ impl TextRenderer { let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32); let c8 = color.to_color_u8(); - let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.alpha()); + // cosmic-text's glyph-Mask rendering drops the base color's alpha + // entirely — its swash `with_pixels` uses the glyph coverage as the + // output alpha (see the "TODO: blend base alpha?" in its source), so + // a translucent text color would render fully opaque. Fold the + // requested alpha back in at blend time below; RGB stays straight. + let base_alpha = c8.alpha(); + let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_alpha); let (width, height) = (pixmap.width() as i32, pixmap.height() as i32); let ox = origin_x as i32; @@ -103,6 +156,12 @@ impl TextRenderer { if a == 0 { return; } + // `a` is the glyph coverage; combine it with the requested + // color alpha for the true source alpha. + let a = (a as u32 * base_alpha as u32 / 255) as u8; + if a == 0 { + return; + } blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a); }, ); @@ -168,4 +227,37 @@ mod tests { // exact glyph coverage depends on whatever fonts are installed on the CI host. assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255)); } + + #[test] + fn draw_line_respects_color_alpha() { + // Regression: cosmic-text's glyph-Mask path drops the base color's + // alpha (coverage becomes the only alpha), so translucent text used to + // render fully opaque — which broke every text fade on the lock screen + // (clock/date/hint/status never faded during appear/unlock). + let mut renderer = TextRenderer::new(); + + let mut full = Pixmap::new(200, 40).unwrap(); + full.fill(tiny_skia::Color::BLACK); + renderer.draw_line( + &mut full, + "12:34", + "sans-serif", + 24.0, + tiny_skia::Color::WHITE, + 0.0, + 0.0, + ); + let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap(); + assert!(full_max > 200, "full-alpha text should render bright, got {full_max}"); + + let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap(); + let mut low = Pixmap::new(200, 40).unwrap(); + low.fill(tiny_skia::Color::BLACK); + renderer.draw_line(&mut low, "12:34", "sans-serif", 24.0, faint, 0.0, 0.0); + let low_max = low.pixels().iter().map(|p| p.red()).max().unwrap(); + assert!( + low_max < 100, + "10%-alpha text must not render near-white, got {low_max}" + ); + } } diff --git a/breadlock.example.toml b/breadlock.example.toml index 223c563..e9e53e6 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -8,10 +8,16 @@ path = "" # v2 feature — accepted but currently just logs a warning and shows the # background unblurred (needs a wlr-screencopy capture, not implemented yet). blur = false +# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the +# background redraws continuously at a low frame rate while locked. +ken_burns = false [clock] # strftime format format = "%H:%M" +# strftime format for the date line under the clock; empty string hides it +# (e.g. %A · %b %d → "Friday · Aug 21") +date_format = "%A · %b %d" [font] family = "Varela Round" @@ -20,3 +26,7 @@ family = "Varela Round" # How long the "wrong password" state (red pill) shows before input # re-enables, in milliseconds. fail_timeout_ms = 800 + +[animation] +# Subtle glow pulse on the password pill every few seconds while idle. +breathe = true diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index 5e2dc70..98355b3 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -17,6 +17,13 @@ path = "src/main.rs" name = "breadlock-auth-check" path = "src/bin/breadlock-auth-check.rs" +# Dev-only harness: renders the lock-screen motion system (render.rs) to a +# folder of PNGs with no Wayland involved, for eyeballing animations without +# locking a session. Not installed by the package. +[[bin]] +name = "breadlock-preview" +path = "src/bin/breadlock-preview.rs" + [dependencies] breadlock-ui = { path = "../breadlock-ui", features = ["paint"] } bread-utils = { workspace = true, features = ["bread-client"] } diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index fa65b49..41cabcb 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -1,14 +1,26 @@ //! Lock-screen background: a solid palette color, or a static image scaled //! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2 //! follow-up (see README) — `blur = true` is accepted but only logs a -//! warning in v1. +//! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to +//! image backgrounds (opt-in: it keeps the background redrawing at a low +//! frame rate while locked). use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +use std::f32::consts::TAU; use tiny_skia::{Pixmap, PixmapPaint, Transform}; +/// One full Ken Burns pan+zoom cycle, in seconds. Deliberately slow so the +/// motion reads as a gentle drift rather than a slideshow. +const KENBURNS_PERIOD_S: f32 = 90.0; +/// Extra zoom beyond plain cover-fit — gives the pan room to travel without +/// ever exposing the image edges. +const KENBURNS_ZOOM: f32 = 1.06; + pub enum Background { Color(tiny_skia::Color), - Image(Pixmap), + /// `(source, ken_burns)` — the flag decides whether `paint` pans over + /// time or draws statically. + Image(Pixmap, bool), } impl Background { @@ -31,7 +43,7 @@ impl Background { return fallback(); } match Pixmap::load_png(&cfg.path) { - Ok(pixmap) => Background::Image(pixmap), + Ok(pixmap) => Background::Image(pixmap, cfg.ken_burns), Err(err) => { tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); fallback() @@ -41,25 +53,51 @@ impl Background { } } + /// True when this background needs continuous redraws (Ken Burns pan). + pub fn ken_burns(&self) -> bool { + matches!(self, Background::Image(_, true)) + } + /// Paints this background into `target`, cover-fit (scaled uniformly to - /// fill the surface, cropping any overflow — never letterboxed). - pub fn paint(&self, target: &mut Pixmap) { + /// fill the surface, cropping any overflow — never letterboxed). `t_secs` + /// is the monotonic clock: with Ken Burns enabled the image slowly pans + /// and zooms along a smooth Lissajous-ish drift, so consecutive frames + /// differ slightly but never jump. + pub fn paint(&self, target: &mut Pixmap, t_secs: f32) { match self { Background::Color(c) => target.fill(*c), - Background::Image(source) => { + Background::Image(source, ken_burns) => { let (tw, th) = (target.width() as f32, target.height() as f32); let (sw, sh) = (source.width() as f32, source.height() as f32); if sw <= 0.0 || sh <= 0.0 { return; } - let scale = (tw / sw).max(th / sh); + let cover = (tw / sw).max(th / sh); + let (scale, tx, ty) = if *ken_burns { + let scale = cover * KENBURNS_ZOOM; + // Pan range: how far the scaled image overhangs each axis. + let pan_x = (sw * scale - tw).max(0.0); + let pan_y = (sh * scale - th).max(0.0); + let phase = t_secs * TAU / KENBURNS_PERIOD_S; + // Sin/cos offset by a quarter cycle: the pan traces a slow + // ellipse, starting from a corner. + ( + scale, + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (cover, 0.0, 0.0) + }; target.fill(tiny_skia::Color::BLACK); target.draw_pixmap( 0, 0, source.as_ref(), &PixmapPaint::default(), - Transform::from_scale(scale, scale), + // scale first (image coords → scaled), then translate into + // the pan position. + Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)), None, ); } diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs new file mode 100644 index 0000000..d8ae223 --- /dev/null +++ b/breadlock/src/bin/breadlock-preview.rs @@ -0,0 +1,153 @@ +//! Dev-only harness: renders the breadlock lock-screen motion system to a +//! folder of PNGs so the new animations can be eyeballed without locking a +//! session (or even touching Wayland). Every scene below pins concrete +//! progress values into `render::FrameInputs` — the same struct the real +//! locker feeds from live timestamps — so what you see here is exactly what +//! `state.rs` computes at runtime. +//! +//! Not installed by the package; run from a build tree with +//! `cargo run --bin breadlock-preview [out-dir]` (default `preview/`). +//! Scenes are written as `NN-.png` in alphabetical-file order, so a +//! file manager or `for f in preview/*.png; do ...` steps through them as a +//! flipbook roughly in timeline order. + +use breadlock_ui::painter::TextRenderer; +use breadlock_ui::theme; +use render::{compose, FrameInputs}; + +// Reuse the real renderer + background code via the same `#[path]` include +// trick as `breadlock-auth-check` (dev bins are separate crates and can't see +// `main.rs`'s modules otherwise). `render.rs` pulls `crate::background::Background`, +// which this crate root provides below. Only `compose`/`FrameInputs` are used +// here; the compositor-side helpers (blit_to_shm, the timing consts) stay +// included so this harness exercises the *real* renderer, so dead-code is +// expected and silenced. +#[allow(dead_code)] +#[path = "../background.rs"] +mod background; + +#[allow(dead_code)] +#[path = "../render.rs"] +mod render; + +const W: u32 = 960; +const H: u32 = 540; +const FONT: &str = "Varela Round"; + +struct Scene { + name: &'static str, + clock: &'static str, + date: &'static str, + clock_old: Option<(&'static str, f32)>, + password_len: usize, + failed: bool, + failed_t: f32, + dot_pop_t: f32, + keystroke_age: Option, + /// Idle caret blink phase driver (`t_secs` in FrameInputs). Only matters + /// for scenes with no keystroke age: phase = (t × 1.8) % 1.0, caret is + /// lit below 0.5. + t_secs: f32, + status: Option<&'static str>, + appear_t: f32, + unlock_t: f32, + breathe_t: f32, + status_t: f32, +} + +impl Default for Scene { + fn default() -> Self { + Self { + name: "", + clock: "12:34", + date: "Friday · Aug 21", + clock_old: None, + password_len: 0, + failed: false, + failed_t: 0.0, + dot_pop_t: 1.0, + keystroke_age: None, + t_secs: 0.2, + status: None, + appear_t: 1.0, + unlock_t: 0.0, + breathe_t: 0.0, + status_t: 1.0, + } + } +} + +fn main() { + let out_dir = std::env::args() + .nth(1) + .unwrap_or_else(|| "preview".to_string()); + std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir"); + + let palette = theme::load_palette(); + let background = background::Background::load( + &breadlock_ui::config::Background::default(), + &palette, + ); + + let scenes = [ + // ---- Staggered entrance: clock leads, pill pops in last (overshoot). + Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() }, + Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() }, + Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() }, + // ---- Rest pose: empty pill showing the "Enter password" hint. + Scene { name: "04-rest-pose", t_secs: 0.5, ..Scene::default() }, + // ---- Idle breath: glow peak on the pill (accent ring + deeper shadow). + Scene { name: "05-breathe-peak", breathe_t: 1.0, ..Scene::default() }, + // ---- Typing: newest dot mid-pop, caret solid. + Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() }, + // ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle). + Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() }, + // ---- Checking: status mid slide-in with the animated ellipsis. + Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..Scene::default() }, + // ---- Wrong password: mid-shake, red pill, red status (settled). + Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() }, + // ---- Success: green flash ring, dots cascading accent → white. + Scene { name: "10-success-flash", password_len: 6, unlock_t: 0.12, ..Scene::default() }, + // ---- Unlock fade-out: chrome faded, parallax drift (clock furthest). + 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() }, + ]; + + let mut text = TextRenderer::new(); + let mut count = 0; + for scene in &scenes { + let inputs = FrameInputs { + width: W, + height: H, + background: &background, + palette: &palette, + font_family: FONT, + clock_text: scene.clock, + date_text: scene.date, + clock_old: scene.clock_old, + password_len: scene.password_len, + failed: scene.failed, + failed_t: scene.failed_t, + dot_pop_t: scene.dot_pop_t, + keystroke_age: scene.keystroke_age, + t_secs: scene.t_secs, + breathe_t: scene.breathe_t, + status_t: scene.status_t, + status_text: scene.status, + appear_t: scene.appear_t, + unlock_t: scene.unlock_t, + }; + let Some(pixmap) = compose(&mut text, &inputs) else { + eprintln!("compose returned None for scene {}", scene.name); + std::process::exit(1); + }; + let path = format!("{}/{}.png", out_dir, scene.name); + pixmap + .save_png(&path) + .unwrap_or_else(|err| panic!("failed to write {path}: {err}")); + count += 1; + println!("wrote {path}"); + } + println!("{count} frames → {out_dir}/"); +} diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index 49579e7..4cf6252 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { #[serde(flatten)] pub appearance: Appearance, pub input: Input, + pub animation: Animation, } #[derive(Debug, Clone, Deserialize)] @@ -25,6 +26,23 @@ impl Default for Input { } } +/// Idle animation toggles. Everything here runs on a low-duty-cycle timer so +/// the software-rendered lock screen doesn't burn CPU while idle. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Animation { + /// Subtle glow pulse on the password pill every few seconds — proves the + /// screen is live, not frozen. Runs only during a short active window of + /// each cycle (see `BREATHE_*` in render.rs). + pub breathe: bool, +} + +impl Default for Animation { + fn default() -> Self { + Self { breathe: true } + } +} + pub fn load() -> Config { breadlock_ui::config::load_or_default(&config_path()) } @@ -46,6 +64,11 @@ mod tests { assert_eq!(Config::default().input.fail_timeout_ms, 800); } + #[test] + fn default_animation_breathe_is_on() { + assert!(Config::default().animation.breathe); + } + #[test] fn flattened_appearance_parses_alongside_input() { let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n"; diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index c90d229..fb3c10f 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -2,6 +2,7 @@ use smithay_client_toolkit::seat::keyboard::{ KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, }; use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState}; +use std::time::Instant; use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface}; use wayland_client::{Connection, QueueHandle}; use zeroize::Zeroize; @@ -179,6 +180,10 @@ impl AppState { for ch in text.chars().filter(|c| !c.is_control()) { self.password.push(ch); } + // Only keystrokes that *grew* the password re-prime the + // newest-dot pop-in and the caret's solid phase (see the + // `last_keystroke` field doc in state.rs). + self.last_keystroke = Some(Instant::now()); self.clear_failed_state(); } } @@ -190,6 +195,9 @@ impl AppState { fn clear_failed_state(&mut self) { if matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError) { self.auth_state = AuthState::Idle; + // Drop the red-pill tint and shake offsets; `failed_at` is also + // cleared so `schedule_clear_failed`'s timer is a no-op. + self.failed_at = None; } } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index dde44f7..9d9d4e4 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -177,10 +177,12 @@ fn run_lock() { "PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed" ); state.auth_state = AuthState::ConfigError; + state.failed_at = Some(std::time::Instant::now()); } auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { tracing::warn!(%err, "authentication failed"); state.auth_state = AuthState::Failed; + state.failed_at = Some(std::time::Instant::now()); } } state.schedule_clear_failed(auth_result_qh.clone()); @@ -218,8 +220,20 @@ fn run_lock() { password: zeroize::Zeroizing::new(String::with_capacity(128)), auth_state: AuthState::Idle, auth_tx, + started: std::time::Instant::now(), appear_started: None, unlocking: None, + last_keystroke: None, + failed_at: None, + last_clock_text: String::new(), + clock_anim_started: None, + status_anim_started: None, + last_auth_state: AuthState::Idle, + breathe_started: None, + breathe_next_at: Some( + std::time::Instant::now() + + std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS), + ), anim_timer_armed: false, exit: false, }; diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index acc7bc9..fe35bcc 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -1,6 +1,11 @@ -//! Frame composition: paints one full lock-screen frame (background, -//! password pill, clock, status line) into a `tiny_skia::Pixmap`, then -//! copies it into a Wayland `wl_shm` buffer. +//! Frame composition: paints one full lock-screen frame (background, clock, +//! date, password pill, dots/caret, status line) into a `tiny_skia::Pixmap`, +//! then copies it into a Wayland `wl_shm` buffer. +//! +//! Motion: every effect is driven by raw 0..1 progress inputs (see +//! [`FrameInputs`]) that `state.rs` computes from timestamps; this module only +//! turns progress into pixels, so the whole timeline is unit-testable off a +//! compositor (see `breadlock-preview`, which renders frames to PNG). //! //! tiny-skia's in-memory pixel format is byte-order RGBA; `wl_shm`'s //! `Argb8888` format is host-endian `0xAARRGGBB`, i.e. byte-order BGRA on @@ -9,19 +14,88 @@ use crate::background::Background; use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; use breadlock_ui::theme::tiny_skia_color; +use std::f32::consts::PI; use std::time::Instant; -use tiny_skia::{Color, Paint, Pixmap}; +use tiny_skia::{Color, Paint, Pixmap, Rect, Transform}; -/// Lock-appear duration: overlay fades in and eases up from below rest. +/// Lock-appear duration: elements ease in on a small stagger (see the +/// `*_DELAY_MS` consts) instead of one uniform fade. pub const APPEAR_MS: u64 = 450; -/// Unlock-fade duration: overlay fades out with a slight upward drift. -pub const UNLOCK_MS: u64 = 400; -/// Redraw cadence while an animation is in flight (~60 Hz). +/// Total unlock duration: [`FLASH_MS`] of green success flash, then a +/// fade-out with a slight upward drift. +pub const UNLOCK_MS: u64 = 650; +/// Green success-flash phase at the start of the unlock. +pub const FLASH_MS: u64 = 250; +/// Wrong-password shake duration (the red pill stays up until +/// `input.fail_timeout_ms`, which outlives the shake). +pub const SHAKE_MS: u64 = 380; +/// Newest password-dot pop-in duration. +pub const DOT_POP_MS: u64 = 200; +/// Minute-rollover crossfade duration. +pub const CLOCK_CROSSFADE_MS: u64 = 300; +/// Redraw cadence while any fast animation is in flight (~60 Hz). pub const ANIM_FRAME_MS: u64 = 16; +/// Cadence while only slow effects are running (idle breath, Ken Burns pan). +pub const SLOW_FRAME_MS: u64 = 62; +/// Status-line slide-in duration ("Checking…" / "Wrong password" rise in). +pub const STATUS_SLIDE_MS: u64 = 200; +/// Idle breathing (config `animation.breathe`): a subtle glow pulse on the +/// pill every few seconds. Only the active window redraws (low-duty-cycle +/// timer in state.rs), so idle CPU stays near zero. +pub const BREATHE_PERIOD_MS: u64 = 4000; +pub const BREATHE_ACTIVE_MS: u64 = 1200; +/// How long after the lock appears before the first breath. +pub const BREATHE_INITIAL_DELAY_MS: u64 = 1500; const APPEAR_SLIDE_PX: f32 = 28.0; const UNLOCK_DRIFT_PX: f32 = 20.0; -const DIM_ALPHA: f32 = 0.28; +/// Parallax: during the unlock fade each element drifts up at a slightly +/// different speed (clock furthest, status least) for a sense of depth. +const DRIFT_CLOCK: f32 = 1.25; +const DRIFT_DATE: f32 = 1.25; +const DRIFT_PILL: f32 = 1.0; +const DRIFT_STATUS: f32 = 0.8; +/// Peak glow multiplier added to the pill shadow during an idle breath, and +/// the accent-ring alpha at the breath peak (sketch's `breathe` keyframes). +const BREATHE_GLOW: f32 = 0.6; +const BREATHE_RING_ALPHA: f32 = 0.12; +/// How far the status line rises during its slide-in. +const STATUS_SLIDE_PX: f32 = 8.0; +/// Dim veil over the wallpaper: a vertical gradient, darker at the top so +/// the clock (in the upper third) sits on the deepest tone. +const DIM_ALPHA_TOP: f32 = 0.34; +const DIM_ALPHA_BOTTOM: f32 = 0.16; +/// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`). +const PILL_BORDER_ALPHA: f32 = 0.10; +/// Fake drop-shadow layers under the pill (tiny-skia has no blur filter): +/// `(grow_px, black_alpha)` — a few concentric copies at fading alpha read +/// as a soft shadow when drawn under the fill. +const PILL_SHADOW: [(f32, f32); 3] = [(2.5, 0.20), (5.0, 0.11), (7.5, 0.05)]; +/// Clock/date scale with the surface, clamped to the sketch's CSS ranges +/// (`clamp(34px, 7.5vw, 60px)` and `clamp(11px, 1.6vw, 14px)`). +const CLOCK_SIZE_MIN: f32 = 34.0; +const CLOCK_SIZE_MAX: f32 = 60.0; +const DATE_SIZE_MIN: f32 = 11.0; +const DATE_SIZE_MAX: f32 = 14.0; +/// Fraction of the shake window over which the pill tints to red (smooth +/// transition instead of an instant color swap). +const SHAKE_RED_FRAC: f32 = 150.0 / SHAKE_MS as f32; +/// Fraction of the unlock window that is the green flash. +const FLASH_FRAC: f32 = FLASH_MS as f32 / UNLOCK_MS as f32; +/// Entrance stagger: each element's appear starts this many ms into the +/// `APPEAR_MS` window, so the clock leads and the status trails. +const CLOCK_DELAY_MS: u64 = 0; +const DATE_DELAY_MS: u64 = 80; +const PILL_DELAY_MS: u64 = 100; +const STATUS_DELAY_MS: u64 = 160; +/// Dot/caret geometry. Dot diameter matches the sketch's 6–9px range. +const DOT_R: f32 = 4.5; +const DOT_GAP: f32 = 18.0; +const CARET_W: f32 = 2.0; +/// Seconds between caret blinks while idle; solid for the first `CARET_HOLD_S` +/// after a keystroke (terminal-style). +const CARET_BLINK_HZ: f32 = 1.8; +const CARET_HOLD_S: f32 = 0.5; pub struct FrameInputs<'a> { pub width: u32, @@ -30,10 +104,27 @@ pub struct FrameInputs<'a> { pub palette: &'a breadlock_ui::theme::Palette, pub font_family: &'a str, pub clock_text: &'a str, + /// Date line under the clock. Empty string hides it. + pub date_text: &'a str, + /// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`. + pub clock_old: Option<(&'a str, f32)>, pub password_len: usize, - /// True while showing a failed-attempt state (red pill). No animated - /// shake in v1 — just a color/status-text indicator. + /// 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. + pub failed_t: f32, + /// Raw 0..1 progress of the newest dot's pop-in. 1 when no pop is live. + pub dot_pop_t: f32, + /// Seconds since the most recent keystroke (caret solid/blink behavior). + pub keystroke_age: Option, + /// Monotonic seconds since app start (idle caret blink cadence, Ken Burns + /// pan phase). + pub t_secs: f32, + /// Idle-breathing envelope: 0 when no breath is active, ramping 0..1..0 + /// (one sine hump) over the active window. Scales the pill's glow. + pub breathe_t: f32, + /// Status-line slide-in progress (0..1, 1 settled). + pub status_t: f32, pub status_text: Option<&'a str>, /// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose. pub appear_t: f32, @@ -48,6 +139,23 @@ pub fn ease_out_cubic(t: f32) -> f32 { 1.0 - inv * inv * inv } +/// Ease-out-back: overshoots past 1 (~5%) then settles — used for the pill's +/// entrance scale so it pops instead of sliding. +pub fn ease_out_back(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) +} + +/// Horizontal shake offset in px for raw progress `t` in 0..1 — a damped +/// sinusoid that starts and ends at rest. +pub fn damped_shake_x(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let env = (1.0 - t) * (1.0 - t); + env * 9.0 * (PI * 5.5 * t).sin() +} + /// Linear 0..1 progress since `started` over `duration_ms`. pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 { let dur = duration_ms as f32 / 1000.0; @@ -57,7 +165,25 @@ pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 { (started.elapsed().as_secs_f32() / dur).clamp(0.0, 1.0) } -/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress. +/// Idle-breath envelope: a single sine hump over the active window (0 at the +/// start and end, 1 at the peak). +pub fn breathe_envelope(t: f32) -> f32 { + (PI * t.clamp(0.0, 1.0)).sin() +} + +/// Appear progress for a single staggered element: raw overall progress +/// `appear_t` is spread over the `APPEAR_MS` window; the element only starts +/// moving `delay_ms` in. +fn staggered_t(appear_t: f32, delay_ms: u64) -> f32 { + if APPEAR_MS <= delay_ms { + return appear_t; + } + let window = (APPEAR_MS - delay_ms) as f32; + ((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0) +} + +/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress — +/// used for the full-screen dim veil, which fades with the whole chrome. pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) { let appear = ease_out_cubic(appear_t); let unlock = ease_out_cubic(unlock_t); @@ -71,129 +197,397 @@ fn faded(mut color: Color, alpha: f32) -> Color { color } +fn lerp_color(a: Color, b: Color, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + let mix = |x: f32, y: f32| x + (y - x) * t; + Color::from_rgba( + mix(a.red(), b.red()), + mix(a.green(), b.green()), + mix(a.blue(), b.blue()), + mix(a.alpha(), b.alpha()), + ) + .unwrap_or(a) +} + /// Composes one frame. Returns `None` only if `width`/`height` are degenerate /// (a `0x0` `configure`, which some compositors send transiently). pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; - inputs.background.paint(&mut pixmap); + inputs.background.paint(&mut pixmap, inputs.t_secs); - let (alpha, y_off) = overlay_motion(inputs.appear_t, inputs.unlock_t); - if alpha <= 0.0 { + // Overall chrome fade: appear eased in, unlock eased out. The unlock + // `fade` multiplies every element below. + 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 { return Some(pixmap); } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), alpha); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), alpha); + 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 on_surface = faded( tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), - alpha, + veil_alpha, ); - let red_color = faded(tiny_skia_color(&inputs.palette.color1), alpha); + let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); - // Translucent veil over the (static) wallpaper — fades with the chrome. - if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, w, h) { + // Translucent veil over the (static) wallpaper — a vertical gradient + // (deeper at the top) that fades in with the chrome. + if let Some(shader) = tiny_skia::LinearGradient::new( + tiny_skia::Point::from_xy(0.0, 0.0), + tiny_skia::Point::from_xy(0.0, h), + vec![ + tiny_skia::GradientStop::new( + 0.0, + Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_TOP * veil_alpha) + .unwrap_or(Color::TRANSPARENT), + ), + tiny_skia::GradientStop::new( + 1.0, + Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_BOTTOM * veil_alpha) + .unwrap_or(Color::TRANSPARENT), + ), + ], + tiny_skia::SpreadMode::Pad, + Transform::identity(), + ) { let mut paint = Paint::default(); - paint.set_color( - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA * alpha).unwrap_or(Color::TRANSPARENT), - ); - pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); + paint.shader = shader; + if let Some(rect) = Rect::from_xywh(0.0, 0.0, w, h) { + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } } - // Clock, large, centered in the upper third. - let clock_size = 64.0; - let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, - inputs.clock_text, - inputs.font_family, - clock_size, - faded(Color::WHITE, alpha), - (w - clock_w) / 2.0, - h * 0.28 + y_off, - ); + // Per-element staggered entrance. + let clock_e = ease_out_cubic(staggered_t(inputs.appear_t, CLOCK_DELAY_MS)); + let date_e = ease_out_cubic(staggered_t(inputs.appear_t, DATE_DELAY_MS)); + let pill_t = staggered_t(inputs.appear_t, PILL_DELAY_MS); + let pill_e = ease_out_cubic(pill_t); + let pill_scale = ease_out_back(pill_t); + let status_e = ease_out_cubic(staggered_t(inputs.appear_t, STATUS_DELAY_MS)); + // Per-element vertical motion: the appear part is uniform, the unlock + // drift is scaled per element for parallax. + let elem_y = |e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift; - // Password pill, centered; turns red while showing a failed attempt. + // ---- Clock, large, centered in the upper third (size scales with the + // surface). A minute rollover crossfades old text out (drifting up) while + // the new fades in from below. + let clock_size = (w * 0.075).clamp(CLOCK_SIZE_MIN, CLOCK_SIZE_MAX); + // Rest-pose anchors: every element drifts from its own rest position, so + // the clock+date and pill+status clusters move as units. (Anchoring the + // date/status to the already-drifted clock/pill *and* adding their own + // `elem_y` would drift them twice, sliding them up into their anchors + // during the unlock.) + let clock_y_rest = h * 0.28; + let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK); + let clock_alpha = clock_e * fade; + match inputs.clock_old { + Some((old, t)) => { + let t = t.clamp(0.0, 1.0); + let old_w = text.measure_line(old, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + old, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha * (1.0 - t)), + (w - old_w) / 2.0, + clock_y - 6.0 * t, + ); + let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + inputs.clock_text, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha * t), + (w - new_w) / 2.0, + clock_y + 6.0 * (1.0 - t), + ); + } + None => { + let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + inputs.clock_text, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha), + (w - clock_w) / 2.0, + clock_y, + ); + } + } + + // ---- Date line under the clock (hidden when date_text is empty). The + // clock's `origin_y` anchors its *top*, so the date is placed below the + // clock's actual glyph box (exact per font, cached) with a small gap. + if !inputs.date_text.is_empty() { + let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); + let (clock_top, clock_height) = + text.measure_box(inputs.clock_text, inputs.font_family, clock_size); + let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + + tokens::SPACE_SM as f32; + let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); + text.draw_line( + &mut pixmap, + inputs.date_text, + inputs.font_family, + date_size, + faded(Color::WHITE, date_e * fade * 0.82), + (w - date_w) / 2.0, + date_y, + ); + } + + // ---- Password pill, centered. Red while failed (tinting in smoothly over + // the first part of the shake), green during the success flash. let pill_w = 280.0_f32.min(w - tokens::SPACE_XL as f32 * 2.0); let pill_h = 48.0; let pill_x = (w - pill_w) / 2.0; - let pill_y = h * 0.5 + y_off; + let pill_y_rest = h * 0.5; + let pill_y = pill_y_rest + elem_y(pill_e, DRIFT_PILL); + let pill_alpha = pill_e * fade; + // Idle breath: glow multiplier on the shadow/border (1 at rest, up to + // 1 + BREATHE_GLOW at the breath peak). + let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t; + + let base_pill = if inputs.failed { + lerp_color(surface_color, red_color, (inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0)) + } else { + surface_color + }; + let pill_color = if inputs.unlock_t > 0.0 { + green_color + } else { + base_pill + }; + // The pill scales about its center (ease-out-back overshoot) instead of + // rising like the text; while unlocking it stays at rest scale. + let scale = if inputs.unlock_t > 0.0 { 1.0 } else { pill_scale }; + let shake_x = if inputs.failed { damped_shake_x(inputs.failed_t) } else { 0.0 }; + let cx = pill_x + pill_w / 2.0; + let cy = pill_y + pill_h / 2.0; + let pill_xf = Transform::from_row( + scale, + 0.0, + 0.0, + scale, + cx * (1.0 - scale) + shake_x, + cy * (1.0 - scale), + ); + + if let Some(path) = + rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) + { + // Soft drop shadow first (under the fill): concentric expanded copies + // offset downward at fading alpha. The idle breath scales the glow. + for (grow, alpha) in PILL_SHADOW { + if let Some(shadow_path) = rounded_rect( + pill_x - grow, + pill_y - grow + 3.0, + pill_w + grow * 2.0, + pill_h + grow * 2.0, + tokens::RADIUS_SECONDARY as f32 + grow, + ) { + let mut paint = Paint::default(); + paint.set_color(faded(Color::BLACK, alpha * pill_alpha * breathe)); + paint.anti_alias = true; + pixmap.fill_path( + &shadow_path, + &paint, + tiny_skia::FillRule::Winding, + pill_xf, + None, + ); + } + } - if let Some(path) = rounded_rect( - pill_x, - pill_y, - pill_w, - pill_h, - tokens::RADIUS_SECONDARY as f32, - ) { let mut paint = Paint::default(); - paint.set_color(if inputs.failed { - red_color - } else { - surface_color - }); + paint.set_color(faded(pill_color, pill_alpha)); paint.anti_alias = true; pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), + pill_xf, None, ); + + // Hairline border for depth — dropped on the wrong/success states + // (the sketch sets `border-color: transparent` there). + if !inputs.failed && inputs.unlock_t == 0.0 { + 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 * pill_alpha * (1.0 + 0.4 * inputs.breathe_t), + )); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } + + // Idle breath: a faint accent ring blooms around the pill at the + // breath peak (matches the sketch's `breathe` keyframes). + if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 { + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 1.5; + let mut paint = Paint::default(); + paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha)); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } + + // Success flash: expanding accent ring around the pill for the first + // `FLASH_MS` of the unlock. + if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC { + let flash_t = inputs.unlock_t / FLASH_FRAC; + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 2.0 + 16.0 * flash_t; + let mut paint = Paint::default(); + paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha)); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } } - // Password dots — one filled circle per typed character, capped so a - // very long password can't overflow the pill. - let dot_r = 5.0; - let dot_gap = 18.0; - let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / dot_gap) + // ---- 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. + 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 { - let dots_w = (shown_dots as f32 - 1.0).max(0.0) * dot_gap; - let start_x = pill_x + (pill_w - dots_w) / 2.0; - let dot_y = pill_y + pill_h / 2.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; + let r = if newest && inputs.dot_pop_t < 1.0 { + (DOT_R * ease_out_back(inputs.dot_pop_t)).max(0.4) + } else { + DOT_R + }; + // Success: dots flip accent → white in a quick left-to-right + // cascade over the green flash (each dot finishes (i+1)/n through + // the flash), instead of all flipping at once. + let dot_color = if inputs.failed { + Color::WHITE + } else if inputs.unlock_t > 0.0 { + let flash_t = (inputs.unlock_t / FLASH_FRAC).clamp(0.0, 1.0); + let cascade = (flash_t * shown_dots as f32 - i as f32).clamp(0.0, 1.0); + lerp_color(accent_color, Color::WHITE, cascade) + } else { + accent_color + }; if let Some(path) = - tiny_skia::PathBuilder::from_circle(start_x + i as f32 * dot_gap, dot_y, dot_r) + tiny_skia::PathBuilder::from_circle(start_x + i as f32 * DOT_GAP, dot_y, r) { let mut paint = Paint::default(); - paint.set_color(if inputs.failed { - faded(Color::WHITE, alpha) - } else { - accent_color - }); + paint.set_color(faded(dot_color, pill_alpha)); paint.anti_alias = true; pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), + Transform::identity(), None, ); } } } - // Status line (e.g. "wrong password" / "checking…") below the pill. + // ---- Empty pill: a centered "enter password" hint instead of dots. The + // caret only appears with the first typed character, so the pill reads as + // an input field rather than an empty dark bar. Centered on the exact + // glyph box (origin anchors the text top, not the baseline). + if shown_dots == 0 && !inputs.failed { + let hint = "Enter password"; + let hint_size = tokens::FONT_SIZE_BASE as f32; + let hint_w = text.measure_line(hint, inputs.font_family, hint_size); + let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size); + let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top; + text.draw_line( + &mut pixmap, + hint, + inputs.font_family, + hint_size, + faded(on_surface, pill_alpha * 0.5), + (w - hint_w) / 2.0, + hint_y, + ); + } else if shown_dots > 0 { + // ---- Caret after the last dot: solid for half a second after a + // keystroke, then blinking at ~1.8 Hz. + let caret_x = start_x_for(shown_dots, pill_x, pill_w) + + (shown_dots - 1) as f32 * DOT_GAP + + DOT_R + + 6.0; + let blink = match inputs.keystroke_age { + Some(age) if age < CARET_HOLD_S => 1.0, + Some(age) => ((age - CARET_HOLD_S) * CARET_BLINK_HZ) % 1.0, + None => (inputs.t_secs * CARET_BLINK_HZ) % 1.0, + }; + if blink < 0.5 { + let caret_color = if inputs.failed || inputs.unlock_t > 0.0 { + Color::WHITE + } else { + accent_color + }; + let caret_h = pill_h * 0.5; + if let Some(path) = rounded_rect( + caret_x, + dot_y - caret_h / 2.0, + CARET_W, + caret_h, + 1.0, + ) { + let mut paint = Paint::default(); + paint.set_color(faded(caret_color, pill_alpha)); + paint.anti_alias = true; + pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + None, + ); + } + } + } + + // ---- Status line below the pill (e.g. "wrong password" / "checking…"). + // Slides up 8px with a fade when it appears (state.rs resets `status_t` + // on every auth-state change). if let Some(status) = inputs.status_text { let status_size = tokens::FONT_SIZE_SECONDARY as f32; let status_w = text.measure_line(status, inputs.font_family, status_size); + let status_anim = ease_out_cubic(inputs.status_t); + let status_alpha = status_e * fade * status_anim; + let color = if inputs.failed { red_color } else { on_surface }; text.draw_line( &mut pixmap, status, inputs.font_family, status_size, - on_surface, + faded(color, status_alpha), (w - status_w) / 2.0, - pill_y + pill_h + tokens::SPACE_MD as f32, + pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + + STATUS_SLIDE_PX * (1.0 - status_anim), ); } Some(pixmap) } +/// Recomputes the left edge of the dot row (shared by the dot loop and the +/// caret placement — kept out of `compose` to avoid a long-lived binding). +fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 { + let dots_w = (shown_dots as f32 - 1.0).max(0.0) * DOT_GAP; + pill_x + (pill_w - dots_w) / 2.0 +} + /// 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 @@ -211,6 +605,41 @@ pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) { mod tests { use super::*; + fn inputs<'a>( + bg: &'a Background, + palette: &'a breadlock_ui::theme::Palette, + text: &'a str, + date: &'a str, + password_len: usize, + failed: bool, + failed_t: f32, + dot_pop_t: f32, + appear_t: f32, + unlock_t: f32, + ) -> FrameInputs<'a> { + FrameInputs { + width: 400, + height: 300, + background: bg, + palette, + font_family: "sans-serif", + clock_text: text, + date_text: date, + clock_old: None, + password_len, + failed, + failed_t, + dot_pop_t, + keystroke_age: None, + t_secs: 0.0, + breathe_t: 0.0, + status_t: 1.0, + status_text: None, + appear_t, + unlock_t, + } + } + #[test] fn blit_swizzles_rgba_to_bgra() { let mut pixmap = Pixmap::new(1, 1).unwrap(); @@ -232,8 +661,16 @@ mod tests { palette: &palette, font_family: "sans-serif", clock_text: "12:34", + date_text: "Friday · Aug 21", + clock_old: None, password_len: 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, @@ -242,6 +679,22 @@ mod tests { assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); } + #[test] + fn compose_renders_failed_and_unlock_states() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + // Wrong-password shake mid-flight. + let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0); + assert!(compose(&mut text, &failed).is_some()); + // Success flash phase of the unlock. + let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12); + assert!(compose(&mut text, &success).is_some()); + // Fully faded unlock returns just the background. + let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0); + assert!(compose(&mut text, &done).is_some()); + } + #[test] fn ease_out_cubic_bounds_and_shape() { assert_eq!(ease_out_cubic(0.0), 0.0); @@ -252,6 +705,48 @@ mod tests { assert!(ease_out_cubic(0.5) > 0.5); } + #[test] + fn ease_out_back_overshoots_past_one() { + assert_eq!(ease_out_back(0.0), 0.0); + assert_eq!(ease_out_back(1.0), 1.0); + assert!( + (0..=20).any(|i| ease_out_back(i as f32 / 20.0) > 1.0), + "ease-out-back must overshoot past 1 somewhere in (0, 1)" + ); + } + + #[test] + fn damped_shake_starts_and_ends_at_rest_and_stays_bounded() { + assert_eq!(damped_shake_x(0.0), 0.0); + assert_eq!(damped_shake_x(1.0), 0.0); + for i in 0..=40 { + let x = damped_shake_x(i as f32 / 40.0); + assert!( + x.abs() < 9.5, + "shake amplitude must stay bounded, got {x} at t={}", + i as f32 / 40.0 + ); + } + } + + #[test] + fn staggered_t_spreads_elements_across_the_window() { + // Clock (delay 0) starts immediately; pill (delay 100ms of 450ms) + // only begins after ~22% of the window. + assert_eq!(staggered_t(0.0, CLOCK_DELAY_MS), 0.0); + assert_eq!(staggered_t(0.0, PILL_DELAY_MS), 0.0); + assert!(staggered_t(0.1, CLOCK_DELAY_MS) > 0.0); + assert_eq!(staggered_t(0.1, PILL_DELAY_MS), 0.0); + assert_eq!(staggered_t(1.0, PILL_DELAY_MS), 1.0); + // Monotonic: later raw progress never regresses an element. + let mut prev = 0.0f32; + for i in 0..=20 { + let t = staggered_t(i as f32 / 20.0, STATUS_DELAY_MS); + assert!(t >= prev, "staggered progress regressed: {t} < {prev}"); + prev = t; + } + } + #[test] fn overlay_motion_appear_starts_below_and_fades_in() { let (a0, y0) = overlay_motion(0.0, 0.0); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 4fd5dd6..858bc7e 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -75,6 +75,8 @@ pub struct AppState { pub auth_state: AuthState, pub auth_tx: Sender, + /// Monotonic clock reference — drives the idle caret blink cadence. + pub started: Instant, /// First-frame timestamp for the lock-appear animation. `None` until /// the first non-degenerate redraw so the fade starts when the surface /// is actually visible, not when the process starts. @@ -83,6 +85,30 @@ pub struct AppState { /// overlay fades out; compositor `unlock()` happens only after the /// fade completes. Dying mid-fade leaves the session locked (fail-secure). pub unlocking: Option, + /// Timestamp of the most recent keystroke that grew the password — drives + /// the newest-dot pop-in and the caret's solid-then-blink behavior. + pub last_keystroke: Option, + /// When the failed state was entered — drives the wrong-password shake. + /// Cleared (with the failed state) by typing or `fail_timeout_ms`. + pub failed_at: Option, + /// Clock text drawn last frame; a change starts a minute-rollover + /// crossfade instead of a hard text swap. + pub last_clock_text: String, + /// When the current clock crossfade started. + pub clock_anim_started: Option, + /// When the current status line appeared ("Checking…" / "Wrong password") — + /// drives its slide-in. Reset whenever `auth_state` changes (see + /// `last_auth_state`). + pub status_anim_started: Option, + /// The `auth_state` from the last frame — a change resets the status + /// slide-in so a freshly appearing status rises in instead of popping. + pub last_auth_state: AuthState, + /// When the current idle-breath window started (glow pulse). `None` + /// between breaths. + pub breathe_started: Option, + /// When the next idle-breath window is due — the 1s clock tick arms the + /// animation timer once it's due, so idle CPU stays near zero. + pub breathe_next_at: Option, /// True while a ~16ms animation timer is registered on the event loop. pub anim_timer_armed: bool, @@ -108,11 +134,37 @@ impl AppState { self.appear_started = Some(Instant::now()); } + let now = Instant::now(); let clock_text = chrono::Local::now() .format(&self.config.appearance.clock.format) .to_string(); + let date_text = chrono::Local::now() + .format(&self.config.appearance.clock.date_format) + .to_string(); + // A status line appearing (or changing) resets its slide-in. + if self.auth_state != self.last_auth_state { + self.status_anim_started = Some(now); + self.last_auth_state = self.auth_state; + } + // Idle breath: one sine hump over the active window. When the window + // ends, schedule the next one a full period out (the 1s clock tick + // re-arms the animation timer once it's due). + let breathe_t = if let Some(started) = self.breathe_started { + let p = render::unit_progress(started, render::BREATHE_ACTIVE_MS); + if p >= 1.0 { + self.breathe_started = None; + self.breathe_next_at = + Some(started + Duration::from_millis(render::BREATHE_PERIOD_MS)); + 0.0 + } else { + render::breathe_envelope(p) + } + } else { + 0.0 + }; + // While a PAM check runs, the status dots tick to signal progress. let status_text = match self.auth_state { - AuthState::Checking => Some("Checking…".to_string()), + AuthState::Checking => Some(format!("Checking{}", checking_dots(now))), AuthState::Failed => Some("Wrong password".to_string()), AuthState::ConfigError => Some( "PAM config error — check logs (breadlock service not set up correctly)" @@ -120,6 +172,22 @@ impl AppState { ), AuthState::Idle => None, }; + let status_t = self + .status_anim_started + .map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS)) + .unwrap_or(1.0); + + // Minute rollover: keep the previous clock text for a short crossfade. + let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text { + self.clock_anim_started = Some(now); + Some((self.last_clock_text.clone(), 0.0)) + } else { + self.clock_anim_started + .map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS)) + .filter(|t| *t < 1.0) + .map(|t| (self.last_clock_text.clone(), t)) + }; + self.last_clock_text = clock_text.clone(); let appear_t = self .appear_started @@ -129,6 +197,14 @@ impl AppState { .unlocking .map(|t| render::unit_progress(t, render::UNLOCK_MS)) .unwrap_or(0.0); + let failed_t = self + .failed_at + .map(|t| render::unit_progress(t, render::SHAKE_MS)) + .unwrap_or(0.0); + let dot_pop_t = self + .last_keystroke + .map(|t| render::unit_progress(t, render::DOT_POP_MS)) + .unwrap_or(1.0); let output_palette = self.palette_for_surface(surface); let inputs = render::FrameInputs { @@ -138,8 +214,16 @@ impl AppState { palette: &output_palette, font_family: &self.config.appearance.font.family, clock_text: &clock_text, + date_text: &date_text, + clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), password_len: self.password.len(), failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError), + failed_t, + dot_pop_t, + keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()), + t_secs: self.started.elapsed().as_secs_f32(), + breathe_t, + status_t, status_text: status_text.as_deref(), appear_t, unlock_t, @@ -217,15 +301,74 @@ impl AppState { .unwrap_or(false) } - fn anim_in_progress(&self) -> bool { - self.unlocking.is_some() || self.appear_in_progress() + fn failed_shake_in_progress(&self) -> bool { + self.failed_at + .map(|t| t.elapsed() < Duration::from_millis(render::SHAKE_MS)) + .unwrap_or(false) } - /// Keep requesting frames while appear or unlock-fade is running. + fn dot_pop_in_progress(&self) -> bool { + self.last_keystroke + .map(|t| t.elapsed() < Duration::from_millis(render::DOT_POP_MS)) + .unwrap_or(false) + } + + fn clock_fade_in_progress(&self) -> bool { + self.clock_anim_started + .map(|t| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS)) + .unwrap_or(false) + } + + fn status_slide_in_progress(&self) -> bool { + self.status_anim_started + .map(|t| t.elapsed() < Duration::from_millis(render::STATUS_SLIDE_MS)) + .unwrap_or(false) + } + + fn breathe_in_progress(&self) -> bool { + self.breathe_started.is_some() + } + + /// An idle breath is due when the cycle timer says so (and no breath is + /// already running). The 1s clock tick calls `redraw_all`, which arms the + /// animation timer through here — so the screen stays asleep between + /// breaths. + fn breathe_due(&self) -> bool { + if !self.config.animation.breathe || self.breathe_started.is_some() { + return false; + } + self.breathe_next_at + .map(|t| Instant::now() >= t) + .unwrap_or(false) + } + + /// Any effect still running that needs the animation timer: the fast ones + /// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status + /// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns + /// pan) which run at a reduced cadence — see `tick_animation`. + fn anim_in_progress(&self) -> bool { + self.unlocking.is_some() + || self.appear_in_progress() + || self.failed_shake_in_progress() + || self.dot_pop_in_progress() + || self.clock_fade_in_progress() + || self.status_slide_in_progress() + || self.breathe_in_progress() + || self.breathe_due() + || self.auth_state == AuthState::Checking + || self.background.ken_burns() + } + + /// Keep requesting frames while any effect is running. fn arm_anim_if_needed(&mut self, qh: &QueueHandle) { if self.anim_timer_armed || !self.anim_in_progress() { return; } + // A breath that's due starts its window now, so the first ticked + // frame already shows the start of the hump. + if self.breathe_due() { + self.breathe_started = Some(Instant::now()); + } self.anim_timer_armed = true; let qh = qh.clone(); if self @@ -247,7 +390,20 @@ impl AppState { self.anim_timer_armed = false; TimeoutAction::Drop } else if self.anim_in_progress() { - TimeoutAction::ToDuration(Duration::from_millis(render::ANIM_FRAME_MS)) + // Slow effects (idle breath, Ken Burns) don't need 60fps — halve + // the redraw cost for them. Everything else stays at ~60Hz. + let fast = self.appear_in_progress() + || self.unlock_in_progress() + || self.failed_shake_in_progress() + || self.dot_pop_in_progress() + || self.clock_fade_in_progress() + || self.status_slide_in_progress() + || self.auth_state == AuthState::Checking; + TimeoutAction::ToDuration(Duration::from_millis(if fast { + render::ANIM_FRAME_MS + } else { + render::SLOW_FRAME_MS + })) } else { self.anim_timer_armed = false; TimeoutAction::Drop @@ -281,6 +437,7 @@ impl AppState { .insert_source(Timer::from_duration(timeout), move |_, _, state| { if matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) { state.auth_state = AuthState::Idle; + state.failed_at = None; state.redraw_all(&qh); } TimeoutAction::Drop @@ -288,6 +445,17 @@ impl AppState { } } +/// The animated ellipsis for the "Checking" status while a PAM check runs: +/// cycles "", ".", "..", "…" every ~500ms (driven by the monotonic clock). +fn checking_dots(now: Instant) -> &'static str { + match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 { + 0 => "", + 1 => ".", + 2 => "..", + _ => "…", + } +} + impl ShmHandler for AppState { fn shm_state(&mut self) -> &mut Shm { &mut self.shm diff --git a/design/sketch.html b/design/sketch.html new file mode 100644 index 0000000..f96b1f9 --- /dev/null +++ b/design/sketch.html @@ -0,0 +1,661 @@ + + + + + +breadlock × breadgreet — style & motion sketch + + + + +
+
+

breadlock × breadgreet — style & motion sketch

+
+ + +
+
+

+ Live CSS prototype of the lock screen and greeter, grounded in the real + bread-theme tokens (fixed BOS dark base + pywal accents). The locker's + software renderer (tiny-skia) can reproduce every motion here; the greeter uses the + same CSS engine directly (GTK4). Badges mark what already ships vs what's proposed. +

+
+ bg + surface + red + green + accent +
+
+ +
+

Live stages click a state chip to replay it

+

The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300–450ms).

+ +
+ +
+

breadlock

tiny-skia · 16ms timer loop · render.rs
+
+
+
+
+
21:47date
Friday · Aug 21
+
+ + +
+
+
+
+
+ + + + + +
+ +
+ + +
+

breadgreet

GTK4 · relm4 · CSS in theme.rs
+
+
+
+
+
21:47
+
+ +
+
+
+ + bos — Hyprland icon + +
+
+
+
+
+ + + + +
+ +
+
+ +

Motion library proposed animations, mapped to where each lands

+

Every idea below is prototypeable in CSS first, then ported. Effort: S small · + M medium · L large (protocol/CPU work).

+ +
+
+
Entrance stagger S
+
21:47
+
Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in render.rs.
+ +
+ +
+
Dot pop + caret S
+
+
Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — render.rs dot loop.
+ +
+ +
+
Wrong-password shake S
+
Wrong password
+
The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after fail_timeout_ms.
+ +
+ +
+
Success flash → unlock drift S
+
+
Correct password: green (color2) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in state.rs.
+ +
+ +
+
Clock minute crossfade S
+
21:4721:48
+
300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in render.rs. Greeter: GTK CSS transition.
+ +
+ +
+
Idle breathing S
+
+
Very subtle 3–4s sine on the pill's glow — proof the screen is live, not frozen. Cheap in render.rs; keep amplitude tiny (CPU is software-rendered).
+ +
+ +
+
Greeter card entrance S
+
+
Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS @keyframes in breadgreet/theme.rs.
+ +
+ +
+
Entry focus ring S
+
+
Accent border + soft glow on focus, 200ms transition. Standard GTK CSS :focus — matches the shared stylesheet's "blue on focus" input rule.
+ +
+ +
+
Auth spinner S
+
+
Real gtk::Spinner during Stage::Working instead of static "Checking…" text. One widget swap in breadgreet/main.rs.
+ +
+ +
+
Session icon rows M
+
bos — Hyprland
Hyprland
+
sessions.rs doesn't parse Icon= today. Custom dropdown rows with per-session icons; falls back to a letter tile.
+ +
+ +
+
Wallpaper Ken Burns M
+
+
Slow pan on image wallpapers — just a drifting Transform in background.rs::paint, no new protocol. Gate behind config (CPU cost).
+ +
+
+ +
+
+

breadlock — where things land

+
    +
  • Motion: extend the existing anim_timer/tick_animation loop in state.rs; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).
  • +
  • Frame math: all easing lives in render.rs (ease_out_cubic, overlay_motion). Add ease_out_back for the pill overshoot and a damped sinusoid for the shake.
  • +
  • Success flash: reuse unlocking: Option<Instant> — flash phase 0–250ms, fade 250–650ms, then unlock().
  • +
  • Bigger: live blur-of-desktop needs a wlr-screencopy capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New [animation] config section (enabled / speed / per-effect toggles).
  • +
+
+
+

breadgreet — where things land

+
    +
  • Motion: GTK4 CSS supports @keyframes/animation and transitions — everything goes in breadgreet/theme.rs::load_css, no Rust logic needed for entrance/focus/status.
  • +
  • Spinner: swap the status label for a gtk::Spinner during Stage::Working in main.rs.
  • +
  • Session icons: parse Icon= in sessions.rs and switch DropDown to custom rows.
  • +
  • Unify with the locker: same clock sizing/weight, same radius + spacing tokens; card backdrop-filter: blur() if GTK ≥ 4.12 supports it (README already requires 4.12).
  • +
+
+
+ +
+ Sketch mirrors breadlock/src/render.rs + state.rs, breadgreet/src/theme.rs + main.rs, and the tokens in + bread-ecosystem/BREAD_DESIGN_SYSTEM.md / bread-theme/src/palette.rs. Palette: fixed BOS dark base + (bg #0c0c0c, surface #1a1a1a, overlay #d8d8d8) with pywal-driven accents (color1–6). + The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active. +
+
+ + + +