diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..5b28301 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: ['main'] + +jobs: + check: + runs-on: [self-hosted, hestia] + # Same container/no-JS-actions convention as package.yml: the archlinux + # image has no Node, so every step is a shell command that installs its + # own toolchain and clones manually. Keeps the gate identical to how + # packages are actually built. + container: + image: archlinux:latest + steps: + - name: Install build deps + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git rust cargo clippy rustfmt \ + libgit2 openssl pam wayland libxkbcommon gtk4 + git config --global --add safe.directory '*' + + - name: Checkout + env: + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + # Try the head/ref branch first (e.g. the PR branch); fall back to a + # plain default-branch clone so tags/merge refs still check out. + git clone --depth 1 --branch "$BRANCH" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /src \ + || git clone --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /src + + - name: Format (rustfmt --check) + working-directory: /src + run: cargo fmt --all -- --check + + - name: Lint (clippy, warnings as errors) + working-directory: /src + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Test (all targets) + working-directory: /src + run: cargo test --workspace --all-targets + + - name: Build (release, locked) + working-directory: /src + run: cargo build --release --locked \ No newline at end of file diff --git a/breadgreet/src/greetd/mod.rs b/breadgreet/src/greetd/mod.rs index 81a8d7c..ad77c48 100644 --- a/breadgreet/src/greetd/mod.rs +++ b/breadgreet/src/greetd/mod.rs @@ -30,13 +30,22 @@ pub async fn run_actor(cmd_rx: mpsc::UnboundedReceiver, emit: E) where E: FnMut(Event) + Send + 'static, { - run_actor_with(cmd_rx, emit, Client::connect).await; + run_actor_with(cmd_rx, emit, Client::connect, DEFAULT_ROUNDTRIP_TIMEOUT).await; } +/// Upper bound for a single greetd roundtrip. Without it, a hung PAM module +/// would leave the actor blocked on a `read_from` forever and the UI stuck on +/// the "Working" spinner. When it fires the conversation is cancelled and an +/// [`Event::Error`] is surfaced. Generous on purpose: a slow disk or a +/// deliberate password iterate shouldn't false-positive, but a wedged peer +/// must not wedge the greeter. +const DEFAULT_ROUNDTRIP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + async fn run_actor_with( mut cmd_rx: mpsc::UnboundedReceiver, mut emit: E, mut connect: C, + roundtrip_timeout: std::time::Duration, ) where E: FnMut(Event), C: FnMut() -> Fut, @@ -67,7 +76,26 @@ async fn run_actor_with( } } - let result = exec_cmd(client.as_mut().expect("just connected"), cmd).await; + let result = match tokio::time::timeout( + roundtrip_timeout, + exec_cmd(client.as_mut().expect("just connected"), cmd), + ) + .await + { + Ok(result) => result, + // Hunger guard: the peer accepted our request but never answered. + // Treat it as a wedged connection rather than going through the + // normal `Roundtrip(Err)` handler, whose `cancel_session().await` + // would itself block on a `read_from` against the same dead peer. + // Dropping the client makes the next command reconnect fresh. + Err(_elapsed) => { + client = None; + emit(Event::Error(format!( + "greetd did not respond within {roundtrip_timeout:?}" + ))); + continue; + } + }; match result { CmdResult::Idle => {} CmdResult::Started => emit(Event::SessionStarted), @@ -157,6 +185,7 @@ mod tests { } } }, + std::time::Duration::from_secs(30), ) .await; }); @@ -177,15 +206,12 @@ mod tests { Response::Success.write_to(&mut stream).await.unwrap(); }); - cmd_tx - .send(Command::CreateSession("bob".into())) - .unwrap(); + cmd_tx.send(Command::CreateSession("bob".into())).unwrap(); let ev = ev_rx.recv().await.expect("actor should retry after bind"); match ev { Event::Outcome(Outcome::Success) => {} other => panic!("expected Success, got {other:?}"), } - drop(cmd_tx); server.await.unwrap(); actor.await.unwrap(); @@ -195,4 +221,131 @@ mod tests { ); std::fs::remove_file(&path).ok(); } + + #[tokio::test] + async fn wedged_roundtrip_times_out_instead_of_hanging() { + // A peer that accepts the request but never answers must not leave the + // actor blocked on `read_from` forever; the roundtrip timeout fires, + // the connection is dropped, and the UI is told so it can recover. + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let (ev_tx, mut ev_rx) = mpsc::unbounded_channel(); + + let path = sock("timeout"); + std::fs::remove_file(&path).ok(); + let listener = UnixListener::bind(&path).unwrap(); + + let server = tokio::spawn(async move { + // Accept and read the CreateSession request, then stay wedged: the + // socket stays open (holding `stream`) but no response is ever + // written, so the client hits its roundtrip timeout rather than + // an EOF. + let (mut stream, _) = listener.accept().await.unwrap(); + let _ = Request::read_from(&mut stream).await; + std::future::pending::<()>().await; + }); + + let connect_path = path.clone(); + let actor = tokio::spawn(async move { + run_actor_with( + cmd_rx, + move |ev| { + let _ = ev_tx.send(ev); + }, + move || { + let connect_path = connect_path.clone(); + async move { Client::connect_to(&connect_path).await } + }, + std::time::Duration::from_millis(200), + ) + .await; + }); + + // Let the listener bind and the actor connect, then drive the roundtrip. + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + cmd_tx.send(Command::CreateSession("bob".into())).unwrap(); + + let ev = tokio::time::timeout(std::time::Duration::from_secs(5), ev_rx.recv()) + .await + .expect("actor must surface a timeout promptly, not hang") + .expect("actor must emit an event"); + match ev { + Event::Error(msg) => assert!( + msg.contains("did not respond"), + "expected a roundtrip-timeout error, got {msg:?}" + ), + other => panic!("expected Error, got {other:?}"), + } + + // The actor must not hang on a cancel read against the dead peer. + drop(cmd_tx); + actor.await.unwrap(); + // Abort the never-completing wedged server and reap it. + server.abort(); + let _ = server.await; + std::fs::remove_file(&path).ok(); + } + + #[tokio::test] + async fn roundtrip_connection_error_recovers_on_next_request() { + // A roundtrip that dies mid-conversation (EOF) is a connection error: + // the actor drops the client, surfaces the Error, and then reconnects + // on the next command so a transient blip can't wedge the greeter. + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let (ev_tx, mut ev_rx) = mpsc::unbounded_channel(); + + let path = sock("roundtrip-recover"); + std::fs::remove_file(&path).ok(); + let listener = UnixListener::bind(&path).unwrap(); + + let server = tokio::spawn(async move { + // First connection: read the request, then hang up -> the client + // sees EOF mid-roundtrip. + let (mut stream, _) = listener.accept().await.unwrap(); + let _ = Request::read_from(&mut stream).await; + drop(stream); + // Second connection (after the actor reconnects): answer Success. + let (mut stream, _) = listener.accept().await.unwrap(); + let _ = Request::read_from(&mut stream).await; + let _ = Response::Success.write_to(&mut stream).await; + }); + + let connect_path = path.clone(); + let actor = tokio::spawn(async move { + run_actor_with( + cmd_rx, + move |ev| { + let _ = ev_tx.send(ev); + }, + move || { + let connect_path = connect_path.clone(); + async move { Client::connect_to(&connect_path).await } + }, + std::time::Duration::from_secs(30), + ) + .await; + }); + + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + cmd_tx.send(Command::CreateSession("bob".into())).unwrap(); + let ev = ev_rx.recv().await.expect("first roundtrip error event"); + match ev { + Event::Error(msg) => assert!( + msg.contains("greetd IPC error"), + "expected a connection (EOF) error, got {msg:?}" + ), + other => panic!("expected Error, got {other:?}"), + } + + cmd_tx.send(Command::CreateSession("bob".into())).unwrap(); + let ev = ev_rx.recv().await.expect("recovery success event"); + match ev { + Event::Outcome(Outcome::Success) => {} + other => panic!("expected Success after reconnect, got {other:?}"), + } + + drop(cmd_tx); + server.await.unwrap(); + actor.await.unwrap(); + std::fs::remove_file(&path).ok(); + } } diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 146f0c2..935ad44 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -384,10 +384,9 @@ impl App { Stage::Prompt | Stage::Working => { self.status_lbl.set_label(""); self.status_lbl.remove_css_class("error"); + // `reset_to_username` dispatches the CancelSession itself, so + // we don't double-send it here. self.reset_to_username(); - if self.cmd_tx.send(greetd::Command::CancelSession).is_err() { - self.show_error("Cannot reach greetd"); - } } } } @@ -416,6 +415,17 @@ impl App { self.stage = Stage::Username; self.username.clear(); self.pam_status_held = false; + // Abort any greetd conversation still open server-side. Without this, + // the error/`show_error` reset path returns to the username entry but + // leaves greetd holding a half-done PAM conversation, so the next + // login attempt's CreateSession stacks on a stale session. On a + // broken channel we set the failure label directly rather than + // recursing into `show_error`, which would call back into + // `reset_to_username` forever. + if self.cmd_tx.send(greetd::Command::CancelSession).is_err() { + self.status_lbl.set_label("Cannot reach greetd"); + self.status_lbl.add_css_class("error"); + } if !self.sessions.is_empty() { self.entry.grab_focus(); } diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs index be39dc9..4480b73 100644 --- a/breadgreet/src/sessions.rs +++ b/breadgreet/src/sessions.rs @@ -103,23 +103,36 @@ fn split_exec(exec: &str) -> Vec { fn tokenize_exec(exec: &str) -> Vec { let mut args = Vec::new(); let mut current = String::new(); - let mut in_quote = false; + let mut in_quote = None; // Some('"') or Some('\'') let mut chars = exec.chars().peekable(); while let Some(c) = chars.next() { - match c { - '"' => in_quote = !in_quote, - '\\' if in_quote => { - if let Some(n) = chars.next() { - current.push(n); + match in_quote { + Some(q) => match c { + // Closing the active quote just toggles back to unquoted. + c if c == q => in_quote = None, + // Inside double quotes a backslash escapes the next char + // (freedesktop Exec). Inside single quotes it's literal. + '\\' if q == '"' => match chars.next() { + Some(n) => current.push(n), + None => current.push('\\'), + }, + _ => current.push(c), + }, + None => match c { + '"' | '\'' => in_quote = Some(c), + // Single quotes have no quoting/escaping inside them. + '\\' => match chars.next() { + Some(n) => current.push(n), + None => current.push('\\'), + }, + c if c.is_whitespace() => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } } - } - c if c.is_whitespace() && !in_quote => { - if !current.is_empty() { - args.push(std::mem::take(&mut current)); - } - } - _ => current.push(c), + _ => current.push(c), + }, } } if !current.is_empty() { @@ -173,6 +186,80 @@ mod tests { assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]); } + #[test] + fn split_exec_handles_single_quotes_and_escapes() { + // Single-quoted arguments are one token (previously these split). + assert_eq!(split_exec(r#"cmd 'two words'"#), vec!["cmd", "two words"]); + // A backslash outside quotes escapes the next character, so an + // escaped space merges into the running token. + assert_eq!(split_exec(r"cmd a\ b"), vec!["cmd", "a b"]); + // `\"` inside double quotes is an escaped backslash then a close + // quote, i.e. a literal backslash inside a quoted argument. + assert_eq!(split_exec(r#"cmd "a\"b""#), vec!["cmd", "a\"b"]); + } + + #[test] + fn tokenize_exec_quotes_spaces_and_escapes_edge_cases() { + // Quoting preserves embedded spaces; a backslash escapes a space. + assert_eq!(tokenize_exec("echo \"a b\""), vec!["echo", "a b"]); + assert_eq!(tokenize_exec("echo 'a b'"), vec!["echo", "a b"]); + assert_eq!(tokenize_exec("echo a\\ b"), vec!["echo", "a b"]); + // Inside double quotes a backslash escapes the next character, + // including the quote itself. + assert_eq!(tokenize_exec(r#"echo "a\"b""#), vec!["echo", "a\"b"]); + // An escaped backslash outside quotes yields one literal backslash. + assert_eq!(tokenize_exec(r"echo a\\"), vec!["echo", "a\\"]); + // Quoting can splice mid-word (the space is part of one argument). + assert_eq!( + tokenize_exec(r#"echo pre"mid dle"post"#), + vec!["echo", "premid dlepost"] + ); + // Plain whitespace splits greedily, tabs/newlines included. + assert_eq!(tokenize_exec(" a b\t c\n "), vec!["a", "b", "c"]); + // Field codes survive this layer; `split_exec` drops them later. + assert_eq!( + tokenize_exec("app %U --flag %f"), + vec!["app", "%U", "--flag", "%f"] + ); + // An unclosed quote swallows the remainder as one token (no panic). + assert_eq!(tokenize_exec("app \"rest of"), vec!["app", "rest of"]); + // Empty / whitespace-only input yields no tokens. + assert!(tokenize_exec("").is_empty()); + assert!(tokenize_exec(" \t ").is_empty()); + } + + #[test] + fn tokenize_exec_fuzz_never_panics_and_emits_only_nonempty_tokens() { + // Pseudo-fuzz over quoting/escaping/whitespace/field-code characters: + // whatever the input, tokenizing must not panic and must never emit an + // empty token (the tokenizer only pushes non-empty buffers). + let alphabet: [char; 7] = ['a', 'b', ' ', '\'', '"', '\\', '%']; + let mut state: u64 = 0x2545_F491_4F6C_DD1D; + let mut rng = move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + state + }; + for _ in 0..5_000 { + let len = (rng() % 32) as usize; + let input: String = (0..len) + .map(|_| alphabet[(rng() as usize) % alphabet.len()]) + .collect(); + let tokens = tokenize_exec(&input); + assert!( + tokens.iter().all(|t| !t.is_empty()), + "tokenize must not emit empty tokens for {input:?} -> {tokens:?}" + ); + // Whitespace-only input must yield no tokens. (The converse is + // deliberately not asserted: an input of only a quote/backslash + // adds no word chars and so correctly yields nothing.) + if input.chars().all(char::is_whitespace) { + assert!(tokens.is_empty(), "{input:?} -> {tokens:?}"); + } + } + } + #[test] fn discover_returns_none_when_no_directories_exist() { assert!(discover( @@ -231,21 +318,15 @@ mod tests { assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]); assert_eq!(listed[0].kind, SessionKind::Wayland); assert_eq!(listed[2].kind, SessionKind::X11); - assert!( - listed[2] - .start_env() - .contains(&"XDG_SESSION_TYPE=x11".to_string()) - ); - assert!( - listed[0] - .start_env() - .contains(&"XDG_SESSION_TYPE=wayland".to_string()) - ); - assert!( - listed[0] - .start_env() - .contains(&"XDG_SESSION_DESKTOP=bos".to_string()) - ); + assert!(listed[2] + .start_env() + .contains(&"XDG_SESSION_TYPE=x11".to_string())); + assert!(listed[0] + .start_env() + .contains(&"XDG_SESSION_TYPE=wayland".to_string())); + assert!(listed[0] + .start_env() + .contains(&"XDG_SESSION_DESKTOP=bos".to_string())); std::fs::remove_dir_all(&wayland).ok(); std::fs::remove_dir_all(&x11).ok(); diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs index bce474b..afd8643 100644 --- a/breadlock-ui/src/config.rs +++ b/breadlock-ui/src/config.rs @@ -107,7 +107,10 @@ 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!( + !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 8013304..1ca05b9 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -213,7 +213,14 @@ impl TextRenderer { origin_y: f32, ) { self.draw_line_weighted( - pixmap, text, family, size_px, color, origin_x, origin_y, Weight::NORMAL, + pixmap, + text, + family, + size_px, + color, + origin_x, + origin_y, + Weight::NORMAL, ); } @@ -317,9 +324,8 @@ fn blend_over(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { // out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa). let da = dst.alpha() as u32; let out_a = (sa + da * (255 - sa) / 255) as u8; - let out_c = |c: u8, dc: u8| -> u8 { - (c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8 - }; + let out_c = + |c: u8, dc: u8| -> u8 { (c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8 }; if let Some(blended) = PremultipliedColorU8::from_rgba( out_c(r, dst.red()), out_c(g, dst.green()), @@ -386,7 +392,10 @@ mod tests { 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}"); + 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(); @@ -439,10 +448,7 @@ mod tests { // Full-coverage glyph cores are legitimately opaque, but the AA // edges must carry real intermediate alphas — the old forced-255 // blend made *every* drawn pixel (edges included) fully opaque. - let has_edge = t - .pixels() - .iter() - .any(|p| p.alpha() > 0 && p.alpha() < 255); + let has_edge = t.pixels().iter().any(|p| p.alpha() > 0 && p.alpha() < 255); assert!( has_edge, "glyph AA edges must keep intermediate alphas onto a transparent pixmap" diff --git a/breadlock/src/auth/mod.rs b/breadlock/src/auth/mod.rs index 5346c7a..c0e0abf 100644 --- a/breadlock/src/auth/mod.rs +++ b/breadlock/src/auth/mod.rs @@ -56,9 +56,25 @@ pub fn spawn_check( generation: u64, result_tx: Sender, ) { + // Bound simultaneously-running PAM calls. libpam can't be cancelled, so a + // wedged module would otherwise spawn one uncancellable thread per retry + // (each pinned holding a `Zeroizing` password buffer) with no reclaim — a + // rapid retry against a stuck backend could grow threads without bound. + let Some(slot) = reserve_attempt() else { + tracing::warn!( + in_flight = IN_FLIGHT.load(Ordering::SeqCst), + "PAM attempt rejected: at in-flight cap" + ); + let _ = result_tx.send((generation, Err(AuthError::Authenticate))); + return; + }; std::thread::spawn(move || { let (done_tx, done_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { + // The slot outlives the outer thread's `recv_timeout`: it is only + // freed once the *real* PAM call returns, not when we hand back a + // timed-out failure, so the cap bounds actual outstanding PAM work. + let _slot = slot; let result = pam::check(&username, &password); let _ = done_tx.send(result); }); @@ -75,3 +91,66 @@ pub fn spawn_check( let _ = result_tx.send((generation, result)); }); } + +/// Maximum PAM callbacks in flight at once (see [`spawn_check`]). +const MAX_IN_FLIGHT: usize = 4; + +/// Live PAM-call count backing the cap. +static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +use std::sync::atomic::Ordering; + +/// RAII claim on one in-flight PAM slot; releasing happens on drop, wherever +/// that thread ends. Holding it in the worker thread (not the timouter) is +/// what keeps the cap honest about real outstanding PAM work. +struct InFlightSlot; + +impl Drop for InFlightSlot { + fn drop(&mut self) { + IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Atomically claim one as-yet-unclaimed in-flight slot, or return `None` +/// once [`MAX_IN_FLIGHT`] are running. +fn reserve_attempt() -> Option { + loop { + let current = IN_FLIGHT.load(Ordering::SeqCst); + if current >= MAX_IN_FLIGHT { + return None; + } + if IN_FLIGHT + .compare_exchange_weak(current, current + 1, Ordering::SeqCst, Ordering::Relaxed) + .is_ok() + { + return Some(InFlightSlot); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inflight_cap_denies_at_limit_and_recovers_on_drop() { + // Steer the shared counter to the cap, then confirm a new reserve is + // refused. + IN_FLIGHT.store(MAX_IN_FLIGHT, Ordering::SeqCst); + assert!( + reserve_attempt().is_none(), + "a reserve must be denied at the concurrency cap" + ); + IN_FLIGHT.store(0, Ordering::SeqCst); + + // A free slot is granted, tracked, and released on drop. + let slot = reserve_attempt().expect("a free slot must be granted"); + assert_eq!(IN_FLIGHT.load(Ordering::SeqCst), 1); + drop(slot); + assert_eq!( + IN_FLIGHT.load(Ordering::SeqCst), + 0, + "dropping the slot must release the reservation" + ); + } +} diff --git a/breadlock/src/auth/pam.rs b/breadlock/src/auth/pam.rs index 40dd7a6..d730631 100644 --- a/breadlock/src/auth/pam.rs +++ b/breadlock/src/auth/pam.rs @@ -143,10 +143,7 @@ mod tests { #[test] fn cstr_to_username_copies_nul_terminated_name() { let raw = CString::new("breadway").unwrap(); - assert_eq!( - cstr_to_username(raw.as_ptr()), - Some("breadway".to_string()) - ); + assert_eq!(cstr_to_username(raw.as_ptr()), Some("breadway".to_string())); } #[test] diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index eb8ceac..39a0e4c 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -177,7 +177,21 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: let di = (drow + col) * 4; // SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds); // di + 4 < target size; rows in bounds per above. - unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) }; + unsafe { + lerp4( + sdata, + i00, + i10, + i00 + 4, + i10 + 4, + di, + wx, + wx_inv, + wy, + wy_inv, + dst, + ) + }; } // Last column of this row: clamp x1. let i00 = (r0 + ix + tw - 1) * 4; @@ -198,7 +212,21 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: let i10 = (r1 + ix + col) * 4; let di = (drow + col) * 4; // SAFETY: in bounds as in the interior loop. - unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) }; + unsafe { + lerp4( + sdata, + i00, + i10, + i00 + 4, + i10 + 4, + di, + wx, + wx_inv, + wy, + wy_inv, + dst, + ) + }; } // Last column of the last row (both clamps). let i00 = (r0 + ix + tw - 1) * 4; @@ -339,7 +367,12 @@ impl Background { -scaled.pan_y * (0.5 + 0.5 * phase.cos()), ) } else { - (0.0, 0.0) + // Static wallpaper: center the crop. The source is scaled + // to cover-fit (larger than the target on at least one + // axis) and `blit_translate` samples the region that + // starts at `-tx`, so centering means starting the sample + // window at half the overhang on each axis. + (-scaled.pan_x * 0.5, -scaled.pan_y * 0.5) }; // The cached pixmap is already output-sized, so this per-frame // draw is a 1:1 copy with at most a translation. `draw_pixmap` @@ -409,6 +442,49 @@ mod tests { assert_eq!(px[3].green(), 189); } + #[test] + fn blit_translate_positive_offset_clamps_to_source_start() { + // A positive offset shifts the window before the source's origin and + // must clamp up, showing the top-left of the source rather than + // reading before the buffer or leaving holes. + let src = source_grid(); + let mut dst = Pixmap::new(2, 2).unwrap(); + blit_translate(&mut dst, &src, 5.0, 5.0, false); + let px = dst.pixels(); + assert_eq!(px[0].red(), 0, "positive dx clamps to src(0,0) red"); + assert_eq!(px[0].green(), 0, "positive dy clamps to src(0,0) green"); + assert_eq!(px[3].red(), 63, "(1,1) is src(1,1) red"); + assert_eq!(px[3].green(), 63); + } + + #[test] + fn blit_translate_clamps_each_axis_independently() { + // dx over-clamps to the left edge while dy lands inside the source's + // overhang, so the visible window is src[x 0..2, y 2..4] — each axis + // must clamp in isolation. + let src = source_grid(); + let mut dst = Pixmap::new(2, 2).unwrap(); + blit_translate(&mut dst, &src, 5.0, -3.0, false); + let px = dst.pixels(); + assert_eq!(px[0].red(), 0, "x clamps to source column 0"); + assert_eq!(px[0].green(), 126, "y window starts at source row 2"); + assert_eq!(px[3].red(), 63, "(1,1) is src(1,3) red"); + assert_eq!(px[3].green(), 189); + } + + #[test] + fn blit_translate_exact_fit_is_identity() { + // Equal sizes with zero offset is a plain copy. + let src = source_grid(); + let mut dst = Pixmap::new(4, 4).unwrap(); + blit_translate(&mut dst, &src, 0.0, 0.0, false); + assert_eq!( + dst.data(), + src.data(), + "zero offset at equal size is a copy" + ); + } + #[test] fn ken_burns_pan_never_exposes_edges() { // A small solid-color image panned through a full cycle must cover @@ -424,7 +500,10 @@ mod tests { for i in 0..90 { bg.paint(&mut target, i as f32, true); assert!( - target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30), + target + .pixels() + .iter() + .all(|p| p.red() == 200 && p.green() == 30), "frame {i} exposed an edge" ); } @@ -461,7 +540,86 @@ mod tests { }); let mut target = Pixmap::new(60, 30).unwrap(); bg.paint(&mut target, 0.0, true); - assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30)); + assert!(target + .pixels() + .iter() + .all(|p| p.red() == 200 && p.green() == 30)); + } + + #[test] + fn static_image_crop_is_centered_not_top_left() { + // Regression: a static (non-Ken-Burns) cover-fit image used to render + // the crop anchored at the source's top-left. Here the target is wider + // than it is tall, so the cover-fit layer overhangs vertically. The + // visible region must be centered (matching the GPU path and + // breadgreet), i.e. started at half the overhang. + // + // Source 32x16, target 32x12 -> cover scale 1.0, scaled 32x16, + // pan_y = 4, so the visible window is rows 2..14 when centered but + // rows 0..12 when top-left anchored. A red band in rows 12..16 is + // therefore visible only in the centered crop (rows 12, 13 are within + // 2..14 but outside 0..12), so this fails against the old top-left + // anchoring. + let mut source = Pixmap::new(32, 16).unwrap(); + source.fill(tiny_skia::Color::from_rgba8(255, 220, 0, 255)); // yellow + for y in 12..16 { + for x in 0..32 { + source.pixels_mut()[y * 32 + x] = + tiny_skia::PremultipliedColorU8::from_rgba(255, 0, 0, 255).unwrap(); + } + } + let bg = Background::Image(ImageBg { + source, + ken_burns: false, + cache: RefCell::new(Vec::new()), + }); + + let mut target = Pixmap::new(32, 12).unwrap(); + bg.paint(&mut target, 0.0, false); // integer offset -> pixel-exact memcpy + + // Centered window rows 2..14 includes the red band rows 12..16; a + // top-left window (0..12) would show none. + assert!( + target + .pixels() + .iter() + .any(|p| p.red() == 255 && p.green() == 0), + "centered crop should include the bottom red band; first row {:?}", + target.pixels()[0] + ); + } + + #[test] + fn static_image_crop_is_horizontally_centered() { + // Complementary to the vertical test above: a wide source and a + // equal-height target overhang horizontally. Centering puts the + // visible window at source columns 8..24 (into a 16px target) whereas + // a top-left anchor would use columns 0..16. A red band in columns + // 16..24 is therefore only visible in the centered crop. + let mut source = Pixmap::new(32, 8).unwrap(); + source.fill(tiny_skia::Color::from_rgba8(255, 220, 0, 255)); // yellow + for x in 16..24 { + for y in 0..8 { + source.pixels_mut()[y * 32 + x] = + tiny_skia::PremultipliedColorU8::from_rgba(255, 0, 0, 255).unwrap(); + } + } + let bg = Background::Image(ImageBg { + source, + ken_burns: false, + cache: RefCell::new(Vec::new()), + }); + + let mut target = Pixmap::new(16, 8).unwrap(); + bg.paint(&mut target, 0.0, false); // integer offset -> pixel-exact memcpy + + assert!( + target + .pixels() + .iter() + .any(|p| p.red() == 255 && p.green() == 0), + "centered crop should include the right red band" + ); } #[test] diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index 598f352..1ecac66 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -97,13 +97,24 @@ impl Default for Scene { /// (image background + Ken Burns, full chrome) in a loop and prints per-frame /// timings, so the software renderer's cost can be measured without Wayland. fn bench(args: &[String]) { - let parse = |s: &str, d: &str| -> String { args.iter().find(|a| a.starts_with(s)).map(|a| a[s.len()..].to_string()).unwrap_or_else(|| d.to_string()) }; + let parse = |s: &str, d: &str| -> String { + args.iter() + .find(|a| a.starts_with(s)) + .map(|a| a[s.len()..].to_string()) + .unwrap_or_else(|| d.to_string()) + }; let size: (u32, u32) = { - let v: Vec = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect(); + let v: Vec = parse("--size=", "1920x1200") + .split('x') + .filter_map(|s| s.parse().ok()) + .collect(); (v[0], v[1]) }; let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120); - let path = parse("--wallpaper=", "/home/breadway/.config/breadlock/wallpaper.png"); + let path = parse( + "--wallpaper=", + "/home/breadway/.config/breadlock/wallpaper.png", + ); let palette = theme::load_palette(); let bg_cfg = breadlock_ui::config::Background { @@ -125,25 +136,26 @@ fn bench(args: &[String]) { font_family: FONT, clock_text: "12:34", 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, - keystroke_age: None, - t_secs: 0.0, - breathe_t: 0.0, - status_t: 1.0, - status_text: None, - info_text: "", - 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, + info_text: "", + 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. @@ -157,7 +169,10 @@ fn bench(args: &[String]) { } bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap()); let avg: f64 = bg_times.iter().sum::() / bg_times.len() as f64; - println!("background.paint only: avg {avg:.2} ms max {:.2} ms", bg_times[bg_times.len() - 1]); + println!( + "background.paint only: avg {avg:.2} ms max {:.2} ms", + bg_times[bg_times.len() - 1] + ); } let mut times = Vec::with_capacity(frames as usize); @@ -222,47 +237,138 @@ fn main() { 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 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() }, + 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() }, + 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() }, + 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() }, + 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() }, + Scene { + name: "07-idle-blink", + password_len: 2, + ..Scene::default() + }, // ---- Checking: status mid slide-in. Live submit() zeros the secret // so password_len is 0 — don't fake a filled pill here. - Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, password_len: 0, password: "", ..Scene::default() }, + Scene { + name: "08-checking", + status: Some("Checking…"), + status_t: 0.5, + password_len: 0, + password: "", + ..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() }, + 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() }, + 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() }, + 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() }, + 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() }, + 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() }, + 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: 7, password: "hunter2", reveal: true, ..Scene::default() }, + Scene { + name: "15-reveal", + password_len: 7, + password: "hunter2", + 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() }, + 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() }, + Scene { + name: "17-failed-3x", + password_len: 6, + failed: true, + failed_t: 0.8, + status: Some("Wrong password — 3 failed attempts"), + ..Scene::default() + }, // ---- D-Bus status: now-playing + battery under the clock. - Scene { name: "18-status-info", info: "The War on Drugs — Red Eyes · 87% · charging", ..Scene::default() }, + Scene { + name: "18-status-info", + info: "The War on Drugs — Red Eyes · 87% · charging", + ..Scene::default() + }, ]; let mut text = TextRenderer::new(); diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 1a681aa..9c1b934 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -99,6 +99,12 @@ fn singleton_held(app: &str) -> bool { /// this binary, no args. The child is reaped on a background thread so /// a later unlock cannot leave a zombie under `breadlock listen`. pub fn start_locker() -> Result<(), String> { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + // The child would `Connection::connect_to_env().expect(...)` and die + // with a panic shortly after spawn. Better to report the lock command + // as failed than to leave a coredumping orphan in its stead. + return Err("WAYLAND_DISPLAY is not set; cannot start a Wayland locker".into()); + } let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock")); let mut child = Command::new(exe) .stdin(Stdio::null()) diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index df6c60d..658c0f8 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -103,7 +103,11 @@ pub struct wl_egl_window { #[link(name = "wayland-egl")] extern "C" { - fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window; + fn wl_egl_window_create( + surface: *mut wl_surface, + width: i32, + height: i32, + ) -> *mut wl_egl_window; fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32); fn wl_egl_window_destroy(window: *mut wl_egl_window); } @@ -236,10 +240,16 @@ impl GpuRenderer { egl.initialize(display).ok()?; egl.bind_api(egl::OPENGL_ES_API).ok()?; let mut configs = Vec::with_capacity(1); - egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?; + egl.choose_config(display, &EGL_ATTRIBS, &mut configs) + .ok()?; let config = *configs.first()?; let context = egl - .create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE]) + .create_context( + display, + config, + None, + &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE], + ) .ok()?; // A 1x1 pbuffer is enough to make the context current for setup // before any real lock surface exists. The chosen config is @@ -298,7 +308,9 @@ impl GpuRenderer { let wallpaper = match &bg_cfg.mode { BackgroundMode::Color => None, BackgroundMode::Image if bg_cfg.path.is_empty() => { - tracing::warn!("background.mode = \"image\" but background.path is empty, using solid color"); + tracing::warn!( + "background.mode = \"image\" but background.path is empty, using solid color" + ); None } BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) { @@ -324,9 +336,21 @@ impl GpuRenderer { glow::TEXTURE_MIN_FILTER, glow::LINEAR_MIPMAP_LINEAR as i32, ); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::LINEAR as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_WRAP_S, + glow::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_WRAP_T, + glow::CLAMP_TO_EDGE as i32, + ); } Some(Wallpaper { tex, @@ -356,8 +380,16 @@ impl GpuRenderer { glow::UNSIGNED_BYTE, glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])), ); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::NEAREST as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::NEAREST as i32, + ); } let bg = breadlock_ui::theme::tiny_skia_color(&palette.background); @@ -365,12 +397,28 @@ impl GpuRenderer { // Resolve all uniform locations up front, then drop the closure so // `gl` can move into the renderer. - let (u_screen, u_uv_scale, u_uv_offset, u_tex, u_color, u_dim_top, u_dim_bottom, u_veil_alpha, u_screen_h) = { + let ( + u_screen, + u_uv_scale, + u_uv_offset, + u_tex, + u_color, + u_dim_top, + u_dim_bottom, + u_veil_alpha, + u_screen_h, + ) = { let loc = |p: glow::Program, n: &str| unsafe { gl.get_uniform_location(p, n) }; ( [loc(bg_program, "u_screen"), loc(chrome_program, "u_screen")], - [loc(bg_program, "u_uv_scale"), loc(chrome_program, "u_uv_scale")], - [loc(bg_program, "u_uv_offset"), loc(chrome_program, "u_uv_offset")], + [ + loc(bg_program, "u_uv_scale"), + loc(chrome_program, "u_uv_scale"), + ], + [ + loc(bg_program, "u_uv_offset"), + loc(chrome_program, "u_uv_offset"), + ], [loc(bg_program, "u_tex"), loc(chrome_program, "u_tex")], loc(bg_program, "u_color"), loc(bg_program, "u_dim_top"), @@ -408,7 +456,12 @@ impl GpuRenderer { /// Wraps a lock surface's `wl_surface` in an EGL window + surface. /// Called once per surface from its first `configure`. - pub fn create_surface(&self, surface: &WlSurface, width: u32, height: u32) -> Option { + pub fn create_surface( + &self, + surface: &WlSurface, + width: u32, + height: u32, + ) -> Option { // SAFETY: the surface proxy is live (this is called from its // `configure` handler); the returned window is owned by us. let egl_window = unsafe { @@ -424,8 +477,12 @@ impl GpuRenderer { } // SAFETY: `egl_window` is a valid wl_egl_window native window. let egl_surface = unsafe { - self.egl - .create_window_surface(self.display, self.config, egl_window as *mut c_void, None) + self.egl.create_window_surface( + self.display, + self.config, + egl_window as *mut c_void, + None, + ) }; let egl_surface = match egl_surface { Ok(s) => s, @@ -467,7 +524,12 @@ impl GpuRenderer { } if self .egl - .make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context)) + .make_current( + self.display, + Some(surface.egl_surface), + Some(surface.egl_surface), + Some(self.context), + ) .is_err() { tracing::warn!("eglMakeCurrent failed; skipping GPU frame"); @@ -477,7 +539,11 @@ impl GpuRenderer { unsafe { gl.viewport(0, 0, w as i32, h as i32) }; self.draw_background(w, h, inputs); self.draw_chrome(surface, inputs, text); - if self.egl.swap_buffers(self.display, surface.egl_surface).is_err() { + if self + .egl + .swap_buffers(self.display, surface.egl_surface) + .is_err() + { tracing::warn!("eglSwapBuffers failed; skipping GPU frame"); return false; } @@ -505,7 +571,11 @@ impl GpuRenderer { wf, 0.0, wf, hf, 0.0, hf, ]; gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); - gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + f32s_as_bytes(&verts), + glow::DYNAMIC_DRAW, + ); if let Some(loc) = self.u_screen[0].as_ref() { gl.uniform_2_f32(Some(loc), wf, hf); @@ -567,7 +637,12 @@ impl GpuRenderer { } } - fn draw_chrome(&mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer) { + fn draw_chrome( + &mut self, + surface: &mut GpuSurface, + inputs: &FrameInputs, + text: &mut TextRenderer, + ) { let (w, h) = (surface.width, surface.height); let dirty = surface .chrome_pixmap @@ -686,7 +761,11 @@ impl GpuRenderer { xf0, yf0, xf1, yf0, xf0, yf1, // xf1, yf0, xf1, yf1, xf0, yf1, ]; - gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + f32s_as_bytes(&verts), + glow::DYNAMIC_DRAW, + ); if let Some(loc) = self.u_screen[1].as_ref() { gl.uniform_2_f32(Some(loc), wf, hf); } @@ -711,7 +790,12 @@ impl GpuRenderer { /// Visible source region of the wallpaper for the current pan phase — the /// same cover-fit + Ken Burns math as `background.rs`. -fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32, f32, f32) { +fn pan_region( + wp: (u32, u32), + target: (u32, u32), + ken_burns: bool, + t_secs: f32, +) -> (f32, f32, f32, f32) { let (sw, sh) = (wp.0 as f32, wp.1 as f32); let (tw, th) = (target.0 as f32, target.1 as f32); let cover = (tw / sw).max(th / sh); @@ -727,7 +811,10 @@ fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -pan_y * (0.5 + 0.5 * phase.cos()), ) } else { - (0.0, 0.0) + // Static wallpaper: center the crop, matching the software path in + // `background.rs` (which also samples the static visible region from + // the middle of the cover-fit overhang rather than its top-left). + (-pan_x * 0.5, -pan_y * 0.5) }; (-tx, -ty, scaled_w, scaled_h) } @@ -807,7 +894,12 @@ mod tests { /// re-implemented here so the GPU `pan_region` can be checked against it. /// Software rounds the scaled dims to pixels; GPU keeps floats, so /// compare with a 1px tolerance. - fn software_pan(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32) { + fn software_pan( + wp: (u32, u32), + target: (u32, u32), + ken_burns: bool, + t_secs: f32, + ) -> (f32, f32) { let (sw, sh) = (wp.0 as f32, wp.1 as f32); let (tw, th) = (target.0 as f32, target.1 as f32); let cover = (tw / sw).max(th / sh); @@ -833,8 +925,21 @@ mod tests { let wp = (3840, 2160); let target = (1920, 1200); let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4); - assert_eq!(sx, 0.0, "no ken burns: no horizontal pan"); - assert_eq!(sy, 0.0, "no ken burns: no vertical pan"); + // No ken burns: the visible window is centred in the cover-fit + // overhang, matching the software `Background::Image::paint` path + // (this regressed to a top-left crop before the centering fix). + let pan_x = sw - 1920.0; + let pan_y = sh - 1200.0; + assert!( + (sx - pan_x * 0.5).abs() < 0.01, + "static x crop should be centred, got {sx} vs {}", + pan_x * 0.5 + ); + assert!( + (sy - pan_y * 0.5).abs() < 0.01, + "static y crop should be centred, got {sy} vs {}", + pan_y * 0.5 + ); // Cover fit: the scaled region covers the target in both axes. assert!(sw >= 1920.0 && sh >= 1200.0); // And it's the tightest cover: at least one axis exactly matches. @@ -892,8 +997,14 @@ mod tests { let pan_x = sw - 1920.0; let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0); let pan_y = (2160.0 * (cover * KENBURNS_ZOOM)).round() - 1200.0; - assert!((sx0 - pan_x * 0.5).abs() < 0.5, "at t=0 x should be half-panned, got {sx0}"); - assert!((sy0 - pan_y).abs() < 0.5, "at t=0 y should be fully panned (top), got {sy0}"); + assert!( + (sx0 - pan_x * 0.5).abs() < 0.5, + "at t=0 x should be half-panned, got {sx0}" + ); + assert!( + (sy0 - pan_y).abs() < 0.5, + "at t=0 y should be fully panned (top), got {sy0}" + ); // Half a period later it has returned to the same spot. let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S); assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01); @@ -925,8 +1036,15 @@ mod tests { ] .concat(); for name in [ - "u_screen", "u_uv_scale", "u_uv_offset", "u_tex", "u_color", - "u_dim_top", "u_dim_bottom", "u_veil_alpha", "u_screen_h", + "u_screen", + "u_uv_scale", + "u_uv_offset", + "u_tex", + "u_color", + "u_dim_top", + "u_dim_bottom", + "u_veil_alpha", + "u_screen_h", ] { assert!( declared.iter().any(|d| d == name), @@ -943,7 +1061,8 @@ mod tests { /// top) exactly, or the GPU veil renders upside-down. fn shader_dim_at(frag_y: f32, h: f32) -> f32 { let row = 1.0 - frag_y / h; // 1 at bottom (frag_y=0), 0 at top - crate::render::DIM_ALPHA_TOP + (crate::render::DIM_ALPHA_BOTTOM - crate::render::DIM_ALPHA_TOP) * row + crate::render::DIM_ALPHA_TOP + + (crate::render::DIM_ALPHA_BOTTOM - crate::render::DIM_ALPHA_TOP) * row } #[test] @@ -973,8 +1092,16 @@ mod tests { #[test] fn egl_attribs_are_none_terminated_pairs() { - assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); - assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated"); + assert_eq!( + EGL_ATTRIBS.len() % 2, + 1, + "attribs must be key/value pairs + NONE" + ); + assert_eq!( + *EGL_ATTRIBS.last().unwrap(), + egl::NONE, + "attrib list must be NONE-terminated" + ); let mut saw_window = false; let mut saw_es2 = false; let mut saw_pbuffer = false; diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 665743a..d5659b1 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -170,52 +170,55 @@ fn run_lock() { let loop_handle = event_loop.handle(); let auth_result_qh = qh.clone(); - let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| { - if generation != state.auth_generation { - return; - } - match result { - Ok(()) => { - // Keep the lock surfaces up and fade the overlay out. - // 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; - state.auth_state = AuthState::Idle; - state.checking_started = None; - if state.unlocking.is_none() { - state.unlocking = Some(std::time::Instant::now()); - } + let auth_tx = auth::register( + &loop_handle, + move |state: &mut AppState, generation, result| { + if generation != state.auth_generation { + return; } - Err(err) => { - match err { - // A broken PAM setup (missing/invalid /etc/pam.d/breadlock, - // context init failure) is a config problem, not a typo — - // rendering it identically to "wrong password" would lock - // the user out with zero indication of what's actually - // wrong. Log loudly and show a distinct on-screen message. - auth::AuthError::ContextInit => { - tracing::error!( - %err, - "PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed" - ); - state.enter_fail(AuthState::ConfigError); - } - auth::AuthError::Authenticate => { - tracing::warn!(%err, "authentication failed"); - state.failed_attempts = state.failed_attempts.saturating_add(1); - state.enter_fail(AuthState::Failed); - } - auth::AuthError::AccountInvalid => { - tracing::warn!(%err, "account locked or expired"); - state.enter_fail(AuthState::AccountInvalid); + match result { + Ok(()) => { + // Keep the lock surfaces up and fade the overlay out. + // 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; + state.auth_state = AuthState::Idle; + state.checking_started = None; + if state.unlocking.is_none() { + state.unlocking = Some(std::time::Instant::now()); } } - state.schedule_clear_failed(auth_result_qh.clone()); + Err(err) => { + match err { + // A broken PAM setup (missing/invalid /etc/pam.d/breadlock, + // context init failure) is a config problem, not a typo — + // rendering it identically to "wrong password" would lock + // the user out with zero indication of what's actually + // wrong. Log loudly and show a distinct on-screen message. + auth::AuthError::ContextInit => { + tracing::error!( + %err, + "PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed" + ); + state.enter_fail(AuthState::ConfigError); + } + auth::AuthError::Authenticate => { + tracing::warn!(%err, "authentication failed"); + state.failed_attempts = state.failed_attempts.saturating_add(1); + state.enter_fail(AuthState::Failed); + } + auth::AuthError::AccountInvalid => { + tracing::warn!(%err, "account locked or expired"); + state.enter_fail(AuthState::AccountInvalid); + } + } + state.schedule_clear_failed(auth_result_qh.clone()); + } } - } - state.redraw_all(&auth_result_qh); - }); + state.redraw_all(&auth_result_qh); + }, + ); // D-Bus status (now-playing / battery): the poller posts snapshots here // and each one triggers a redraw so the line under the clock stays live. diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 27290c8..80c3f90 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -411,7 +411,8 @@ fn compose_impl( 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; + let elem_y = + |e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift; // ---- Clock, large, centered in the upper third (size scales with the // surface). A minute rollover crossfades old text out (drifting up) while @@ -428,7 +429,9 @@ fn compose_impl( if let Some(r) = rects.as_deref_mut() { let old_w = inputs .clock_old - .map(|(t, _)| text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD)) + .map(|(t, _)| { + text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD) + }) .unwrap_or(0.0); let new_w = text.measure_line_weighted( inputs.clock_text, @@ -437,12 +440,18 @@ fn compose_impl( Weight::BOLD, ); let cw = old_w.max(new_w); - r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size); + r.expand( + (w - cw) / 2.0, + clock_y, + (w + cw) / 2.0, + clock_y + clock_size, + ); } match inputs.clock_old { Some((old, t)) => { let t = t.clamp(0.0, 1.0); - let old_w = text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD); + let old_w = + text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD); text.draw_line_weighted( pixmap, old, @@ -494,15 +503,27 @@ fn compose_impl( // now-playing / battery under that. Info is *not* nested under the date // — an empty `date_format` used to hide the status line too. let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); - let (clock_top, clock_height) = - text.measure_box_weighted(inputs.clock_text, inputs.font_family, clock_size, Weight::BOLD); - let mut below_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + let (clock_top, clock_height) = text.measure_box_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); + let mut below_y = clock_y_rest + + elem_y(date_e, DRIFT_DATE) + + clock_top + + clock_height + tokens::SPACE_SM as f32; if !inputs.date_text.is_empty() { let date_y = below_y; let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); if let Some(r) = rects.as_mut() { - r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size); + r.expand( + (w - date_w) / 2.0, + date_y, + (w + date_w) / 2.0, + date_y + date_size, + ); } text.draw_line( pixmap, @@ -559,7 +580,11 @@ fn compose_impl( 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)) + lerp_color( + surface_color, + red_color, + (inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0), + ) } else { surface_color }; @@ -570,8 +595,16 @@ fn compose_impl( }; // 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 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( @@ -603,9 +636,13 @@ fn compose_impl( ); } - if let Some(path) = - rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) - { + 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 { @@ -632,13 +669,7 @@ fn compose_impl( let mut paint = Paint::default(); paint.set_color(faded(pill_color, pill_alpha)); paint.anti_alias = true; - pixmap.fill_path( - &path, - &paint, - tiny_skia::FillRule::Winding, - pill_xf, - None, - ); + pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None); // Hairline border for depth — dropped on the wrong/success states // (the sketch sets `border-color: transparent` there). @@ -663,7 +694,10 @@ fn compose_impl( ..Default::default() }; let mut paint = Paint::default(); - paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha)); + paint.set_color(faded( + accent_color, + BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha, + )); pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); } @@ -696,7 +730,8 @@ fn compose_impl( 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_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 @@ -711,7 +746,13 @@ fn compose_impl( // 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); + pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + None, + ); let stroke = tiny_skia::Stroke { width: 1.0, ..Default::default() @@ -808,13 +849,7 @@ fn compose_impl( let mut paint = Paint::default(); paint.set_color(faded(dot_color, pill_alpha)); paint.anti_alias = true; - pixmap.fill_path( - &path, - &paint, - tiny_skia::FillRule::Winding, - pill_xf, - None, - ); + pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None); } } } @@ -859,23 +894,12 @@ fn compose_impl( 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, - ) { + 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, - pill_xf, - None, - ); + pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None); } } } @@ -889,10 +913,18 @@ fn compose_impl( 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 }; - let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + let status_y = pill_y_rest + + pill_h + + tokens::SPACE_MD as f32 + + elem_y(status_e, DRIFT_STATUS) + STATUS_SLIDE_PX * (1.0 - status_anim); if let Some(r) = rects.as_mut() { - r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size); + r.expand( + (w - status_w) / 2.0, + status_y, + (w + status_w) / 2.0, + status_y + status_size, + ); } text.draw_line( pixmap, @@ -968,7 +1000,12 @@ mod tests { let top = px[0]; let bottom = px[2 * 3]; // DIM_ALPHA_TOP (0.34) > DIM_ALPHA_BOTTOM (0.16): top row darker. - assert!(top.red() < bottom.red(), "top {} should be darker than bottom {}", top.red(), bottom.red()); + assert!( + top.red() < bottom.red(), + "top {} should be darker than bottom {}", + top.red(), + bottom.red() + ); // White at top dim 0.34 → 255 * (1 - 0.34) = 168. assert_eq!(top.red(), 168); // Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205. @@ -1083,15 +1120,48 @@ mod tests { 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); + let failed = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + true, + 0.3, + 0.4, + 1.0, + 0.0, + ); let failed_px = compose(&mut text, &failed).expect("failed compose"); assert_eq!((failed_px.width(), failed_px.height()), (400, 300)); assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0)); // Success flash phase of the unlock: still fully opaque chrome (flash // holds rest pose), not already faded. - let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12); + let success = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 0.12, + ); let success_px = compose(&mut text, &success).expect("success compose"); - let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let rest = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 0.0, + ); let rest_px = compose(&mut text, &rest).expect("rest compose"); // Flash frame should not be a near-empty fade — plenty of chrome left. let flash_lit = success_px.pixels().iter().filter(|p| p.alpha() > 0).count(); @@ -1101,7 +1171,18 @@ mod tests { "success flash should keep chrome visible, lit {flash_lit} vs rest {rest_lit}" ); // 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); + let done = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 1.0, + ); let done_px = compose(&mut text, &done).expect("done compose"); assert_eq!((done_px.width(), done_px.height()), (400, 300)); } @@ -1130,11 +1211,17 @@ mod tests { fn reveal_fit_truncates_long_passwords() { let mut text = TextRenderer::new(); // Short password fits unchanged. - assert_eq!(ellipsize(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2"); + assert_eq!( + ellipsize(&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 = ellipsize(&mut text, &long, "sans-serif", 14.0, 60.0); - assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis"); + 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); @@ -1145,7 +1232,18 @@ mod tests { 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); + 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. @@ -1234,8 +1332,14 @@ mod tests { // First FLASH_FRAC of unlock_t is the green flash at full opacity. let (a_rest, y_rest) = overlay_motion(1.0, 0.0); let (a_flash, y_flash) = overlay_motion(1.0, FLASH_FRAC * 0.5); - assert!((a_flash - a_rest).abs() < 1e-6, "flash must not fade chrome, got {a_flash}"); - assert!((y_flash - y_rest).abs() < 1e-6, "flash must not drift chrome, got {y_flash}"); + assert!( + (a_flash - a_rest).abs() < 1e-6, + "flash must not fade chrome, got {a_flash}" + ); + assert!( + (y_flash - y_rest).abs() < 1e-6, + "flash must not drift chrome, got {y_flash}" + ); // After the flash, fade/drift begin. let (a_fade, y_fade) = overlay_motion(1.0, (FLASH_FRAC + 1.0) * 0.5); assert!(a_fade < a_rest, "post-flash should fade, got {a_fade}"); @@ -1248,18 +1352,38 @@ mod tests { let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); let mut pixmap = Pixmap::new(400, 300).unwrap(); - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let inputs = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 0.0, + ); let rect = compose_chrome(&mut pixmap, &mut text, &inputs); assert!( rect.x1 > rect.x0 && rect.y1 > rect.y0, "chrome rect must be non-empty, got {rect:?}" ); // Clock sits at h*0.28 with glyph height ~ clock_size (400*0.075=30). - assert!(rect.y0 < 300.0 * 0.28 + 40.0, "rect must cover the clock band"); + assert!( + rect.y0 < 300.0 * 0.28 + 40.0, + "rect must cover the clock band" + ); // Pill sits at h*0.5; with the 26px pad the rect must reach it. - assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band"); + assert!( + rect.y1 > 300.0 * 0.5 + 24.0, + "rect must cover the pill band" + ); // Both are horizontally centered. - assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center"); + assert!( + rect.x0 < 200.0 && rect.x1 > 200.0, + "rect must straddle center" + ); // Origin-stuck Default(0,0,…) used to pass the checks above (0.min // never leaves 0, and 0 < 200 && x1 > 200 still holds). Fail that. assert!( @@ -1276,9 +1400,23 @@ mod tests { let mut pixmap = Pixmap::new(400, 300).unwrap(); // unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible) // chrome so the GPU dirty rect is valid from the first frame. - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0); + let inputs = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 1.0, + ); let rect = compose_chrome(&mut pixmap, &mut text, &inputs); - assert!(rect.is_empty(), "finished unlock must yield an empty rect, got {rect:?}"); + assert!( + rect.is_empty(), + "finished unlock must yield an empty rect, got {rect:?}" + ); assert!( pixmap.pixels().iter().all(|p| p.alpha() == 0), "finished unlock must leave the pixmap transparent" @@ -1291,7 +1429,18 @@ mod tests { let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); let mut pixmap = Pixmap::new(400, 300).unwrap(); - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + let inputs = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 0.0, + 0.0, + ); let rect = compose_chrome(&mut pixmap, &mut text, &inputs); assert!( !rect.is_empty() && rect.x0 > 0.0 && rect.y0 > 0.0, @@ -1305,9 +1454,31 @@ mod tests { let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); let mut pixmap = Pixmap::new(400, 300).unwrap(); - let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.0, 1.0, 1.0, 0.0); + let rest = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + true, + 0.0, + 1.0, + 1.0, + 0.0, + ); let r0 = compose_chrome(&mut pixmap, &mut text, &rest); - let mid = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0); + let mid = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + true, + 0.3, + 1.0, + 1.0, + 0.0, + ); let r1 = compose_chrome(&mut pixmap, &mut text, &mid); assert!( (r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5, @@ -1328,7 +1499,11 @@ mod tests { n += 1.0; } } - if n > 0.0 { sx / n } else { 0.0 } + if n > 0.0 { + sx / n + } else { + 0.0 + } }; let dx = (centroid_x(&shaken_px) - centroid_x(&rest_px)).abs(); assert!( @@ -1369,7 +1544,18 @@ mod tests { let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); let mut pixmap = Pixmap::new(400, 300).unwrap(); - let mut with_status = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0); + let mut with_status = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + true, + 0.3, + 1.0, + 1.0, + 0.0, + ); with_status.status_text = Some("Wrong password"); let rect = compose_chrome(&mut pixmap, &mut text, &with_status); // Status sits below the pill: pill bottom is h*0.5 + 24 (half of 48px), @@ -1391,14 +1577,27 @@ mod tests { let bg = Background::Color(Color::from_rgba8(40, 60, 80, 255)); let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let inputs = inputs( + &bg, + &palette, + "12:34", + "Friday · Aug 21", + 4, + false, + 0.0, + 1.0, + 1.0, + 0.0, + ); // Full single-pass compose. let full = compose(&mut text, &inputs).unwrap(); // Split: dim the background, then composite the chrome over it. let mut split = Pixmap::new(400, 300).unwrap(); - inputs.background.paint(&mut split, inputs.t_secs, inputs.smooth_pan); + inputs + .background + .paint(&mut split, inputs.t_secs, inputs.smooth_pan); let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); if veil_alpha > 0.0 { dim_rows(&mut split, veil_alpha); @@ -1430,7 +1629,8 @@ mod tests { .iter() .zip(full.pixels()) .map(|(a, b)| { - (a.red() as i32 - b.red() as i32).abs() + (a.red() as i32 - b.red() as i32) + .abs() .max((a.green() as i32 - b.green() as i32).abs()) .max((a.blue() as i32 - b.blue() as i32).abs()) .max((a.alpha() as i32 - b.alpha() as i32).abs()) @@ -1449,5 +1649,3 @@ mod tests { ); } } - - diff --git a/breadlock/src/status.rs b/breadlock/src/status.rs index 84bba49..d7b4d36 100644 --- a/breadlock/src/status.rs +++ b/breadlock/src/status.rs @@ -124,7 +124,10 @@ fn poll_now_playing_on(conn: &zbus::blocking::Connection) -> Result .map_err(|_| ())?; let mut paused: Option = None; - for name in names.iter().filter(|n| n.starts_with("org.mpris.MediaPlayer2.")) { + for name in names + .iter() + .filter(|n| n.starts_with("org.mpris.MediaPlayer2.")) + { let Some((status, title, artist)) = read_player(conn, name) else { continue; }; @@ -160,7 +163,10 @@ fn read_player( .to_string(); let mut title = None; let mut artist = None; - if let Some(metadata) = dict.get("Metadata").and_then(|v| v.downcast_ref::().ok()) { + if let Some(metadata) = dict + .get("Metadata") + .and_then(|v| v.downcast_ref::().ok()) + { title = metadata .get::<&str, &str>(&"xesam:title") .ok() @@ -194,12 +200,8 @@ fn read_player( /// capped; a Playing player with neither still yields the player name (or /// `"Playing"`) so it is not outranked by a later titled Paused player. fn format_now_playing(title: Option<&str>, artist: Option<&str>, player: &str) -> String { - let title = title - .map(sanitize_mpris_field) - .filter(|s| !s.is_empty()); - let artist = artist - .map(sanitize_mpris_field) - .filter(|s| !s.is_empty()); + let title = title.map(sanitize_mpris_field).filter(|s| !s.is_empty()); + let artist = artist.map(sanitize_mpris_field).filter(|s| !s.is_empty()); let line = match (title, artist) { (Some(t), Some(a)) => format!("{t} — {a}"), (Some(t), None) => t, @@ -253,7 +255,11 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result { "GetDisplayDevice", &(), ) - .and_then(|reply| reply.body().deserialize::()) + .and_then(|reply| { + reply + .body() + .deserialize::() + }) .map_err(|_| ())?; let props = conn .call_method( @@ -263,11 +269,7 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result { "GetAll", &("org.freedesktop.UPower.Device",), ) - .and_then(|reply| { - reply - .body() - .deserialize::>() - }) + .and_then(|reply| reply.body().deserialize::>()) .map_err(|_| ())?; // DisplayDevice always exists; without a battery IsPresent is false // and Percentage is often 0. Missing IsPresent is treated as absent.