Fix audit findings, expand tests, and add a CI quality gate
Some checks failed
CI / check (pull_request) Failing after 6s

Addresses the deep codebase audit:

- Center the static wallpaper cover-fit crop so the software (wl_shm)
  path agrees with the GPU path and the greeter, instead of anchoring
  the crop top-left.
- Desktop-entry Exec tokenizer: respect single quotes and proper
  backslash escaping per the freedesktop spec.
- Greeter: dispatch CancelSession whenever the UI resets to the
  username stage so the error path can't leave greetd holding a stale
  PAM conversation; add a per-roundtrip timeout so a wedged greetd peer
  can't strand the "Working" spinner.
- start_locker: return an error instead of spawning a child that
  expect()-panics when WAYLAND_DISPLAY is unset.
- Bound in-flight PAM checks with a concurrency cap, since libpam
  cannot be cancelled and a stuck module would otherwise leak one
  uncancellable thread per retry.
- Expand unit/regression tests (157 total): tokenizer edge cases and a
  pseudo-fuzz, blit offset/clamp cases, horizontal+vertical cover
  centering, greetd roundtrip-timeout and connection-recovery.
- Reformat the workspace to rustfmt-clean and add a Forgejo CI gate
  (fmt --check, clippy -D warnings, all-target tests, locked release
  build) — previously the only workflow was an Arch package builder.
This commit is contained in:
Breadway 2026-08-31 18:46:19 +08:00
parent 94289865c0
commit 1636eb86d0
15 changed files with 1242 additions and 262 deletions

51
.forgejo/workflows/ci.yml Normal file
View file

@ -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

View file

@ -30,13 +30,22 @@ pub async fn run_actor<E>(cmd_rx: mpsc::UnboundedReceiver<Command>, 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<E, C, Fut>(
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
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<E, C, Fut>(
}
}
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();
}
}

View file

@ -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();
}

View file

@ -103,23 +103,36 @@ fn split_exec(exec: &str) -> Vec<String> {
fn tokenize_exec(exec: &str) -> Vec<String> {
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);
}
}
c if c.is_whitespace() && !in_quote => {
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));
}
}
_ => 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]
assert!(listed[2]
.start_env()
.contains(&"XDG_SESSION_TYPE=x11".to_string())
);
assert!(
listed[0]
.contains(&"XDG_SESSION_TYPE=x11".to_string()));
assert!(listed[0]
.start_env()
.contains(&"XDG_SESSION_TYPE=wayland".to_string())
);
assert!(
listed[0]
.contains(&"XDG_SESSION_TYPE=wayland".to_string()));
assert!(listed[0]
.start_env()
.contains(&"XDG_SESSION_DESKTOP=bos".to_string())
);
.contains(&"XDG_SESSION_DESKTOP=bos".to_string()));
std::fs::remove_dir_all(&wayland).ok();
std::fs::remove_dir_all(&x11).ok();

View file

@ -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");

View file

@ -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"

View file

@ -56,9 +56,25 @@ pub fn spawn_check(
generation: u64,
result_tx: Sender<AuthOutcome>,
) {
// 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<InFlightSlot> {
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"
);
}
}

View file

@ -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]

View file

@ -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]

View file

@ -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<u32> = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect();
let v: Vec<u32> = 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,7 +136,8 @@ fn bench(args: &[String]) {
font_family: FONT,
clock_text: "12:34",
date_text: "Friday · Aug 21",
clock_old: None, password_len: 6,
clock_old: None,
password_len: 6,
password: "hunter2",
reveal: false,
caps_lock: false,
@ -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::<f64>() / 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();

View file

@ -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())

View file

@ -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<GpuSurface> {
pub fn create_surface(
&self,
surface: &WlSurface,
width: u32,
height: u32,
) -> Option<GpuSurface> {
// 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;

View file

@ -170,7 +170,9 @@ 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| {
let auth_tx = auth::register(
&loop_handle,
move |state: &mut AppState, generation, result| {
if generation != state.auth_generation {
return;
}
@ -215,7 +217,8 @@ fn run_lock() {
}
}
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.

View file

@ -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 {
);
}
}

View file

@ -124,7 +124,10 @@ fn poll_now_playing_on(conn: &zbus::blocking::Connection) -> Result<String, ()>
.map_err(|_| ())?;
let mut paused: Option<String> = 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::<Dict>().ok()) {
if let Some(metadata) = dict
.get("Metadata")
.and_then(|v| v.downcast_ref::<Dict>().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<String, ()> {
"GetDisplayDevice",
&(),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedObjectPath>())
.and_then(|reply| {
reply
.body()
.deserialize::<zbus::zvariant::OwnedObjectPath>()
})
.map_err(|_| ())?;
let props = conn
.call_method(
@ -263,11 +269,7 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
"GetAll",
&("org.freedesktop.UPower.Device",),
)
.and_then(|reply| {
reply
.body()
.deserialize::<HashMap<String, OwnedValue>>()
})
.and_then(|reply| reply.body().deserialize::<HashMap<String, OwnedValue>>())
.map_err(|_| ())?;
// DisplayDevice always exists; without a battery IsPresent is false
// and Percentage is often 0. Missing IsPresent is treated as absent.