Merge pull request 'Fix audit findings, expand tests, add a CI quality gate' (#3) from fix/deepseek-audit into main
Some checks failed
CI / check (push) Failing after 2s

Reviewed-on: #3
This commit is contained in:
Breadway 2026-08-31 18:56:06 +08:00
commit 758a5d9f9c
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 where
E: FnMut(Event) + Send + 'static, 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>( async fn run_actor_with<E, C, Fut>(
mut cmd_rx: mpsc::UnboundedReceiver<Command>, mut cmd_rx: mpsc::UnboundedReceiver<Command>,
mut emit: E, mut emit: E,
mut connect: C, mut connect: C,
roundtrip_timeout: std::time::Duration,
) where ) where
E: FnMut(Event), E: FnMut(Event),
C: FnMut() -> Fut, 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 { match result {
CmdResult::Idle => {} CmdResult::Idle => {}
CmdResult::Started => emit(Event::SessionStarted), CmdResult::Started => emit(Event::SessionStarted),
@ -157,6 +185,7 @@ mod tests {
} }
} }
}, },
std::time::Duration::from_secs(30),
) )
.await; .await;
}); });
@ -177,15 +206,12 @@ mod tests {
Response::Success.write_to(&mut stream).await.unwrap(); Response::Success.write_to(&mut stream).await.unwrap();
}); });
cmd_tx cmd_tx.send(Command::CreateSession("bob".into())).unwrap();
.send(Command::CreateSession("bob".into()))
.unwrap();
let ev = ev_rx.recv().await.expect("actor should retry after bind"); let ev = ev_rx.recv().await.expect("actor should retry after bind");
match ev { match ev {
Event::Outcome(Outcome::Success) => {} Event::Outcome(Outcome::Success) => {}
other => panic!("expected Success, got {other:?}"), other => panic!("expected Success, got {other:?}"),
} }
drop(cmd_tx); drop(cmd_tx);
server.await.unwrap(); server.await.unwrap();
actor.await.unwrap(); actor.await.unwrap();
@ -195,4 +221,131 @@ mod tests {
); );
std::fs::remove_file(&path).ok(); 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 => { Stage::Prompt | Stage::Working => {
self.status_lbl.set_label(""); self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error"); 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(); 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.stage = Stage::Username;
self.username.clear(); self.username.clear();
self.pam_status_held = false; 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() { if !self.sessions.is_empty() {
self.entry.grab_focus(); self.entry.grab_focus();
} }

View file

@ -103,23 +103,36 @@ fn split_exec(exec: &str) -> Vec<String> {
fn tokenize_exec(exec: &str) -> Vec<String> { fn tokenize_exec(exec: &str) -> Vec<String> {
let mut args = Vec::new(); let mut args = Vec::new();
let mut current = String::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(); let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() { while let Some(c) = chars.next() {
match c { match in_quote {
'"' => in_quote = !in_quote, Some(q) => match c {
'\\' if in_quote => { // Closing the active quote just toggles back to unquoted.
if let Some(n) = chars.next() { c if c == q => in_quote = None,
current.push(n); // Inside double quotes a backslash escapes the next char
} // (freedesktop Exec). Inside single quotes it's literal.
} '\\' if q == '"' => match chars.next() {
c if c.is_whitespace() && !in_quote => { 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() { if !current.is_empty() {
args.push(std::mem::take(&mut current)); args.push(std::mem::take(&mut current));
} }
} }
_ => current.push(c), _ => current.push(c),
},
} }
} }
if !current.is_empty() { if !current.is_empty() {
@ -173,6 +186,80 @@ mod tests {
assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]); 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] #[test]
fn discover_returns_none_when_no_directories_exist() { fn discover_returns_none_when_no_directories_exist() {
assert!(discover( assert!(discover(
@ -231,21 +318,15 @@ mod tests {
assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]); assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]);
assert_eq!(listed[0].kind, SessionKind::Wayland); assert_eq!(listed[0].kind, SessionKind::Wayland);
assert_eq!(listed[2].kind, SessionKind::X11); assert_eq!(listed[2].kind, SessionKind::X11);
assert!( assert!(listed[2]
listed[2]
.start_env() .start_env()
.contains(&"XDG_SESSION_TYPE=x11".to_string()) .contains(&"XDG_SESSION_TYPE=x11".to_string()));
); assert!(listed[0]
assert!(
listed[0]
.start_env() .start_env()
.contains(&"XDG_SESSION_TYPE=wayland".to_string()) .contains(&"XDG_SESSION_TYPE=wayland".to_string()));
); assert!(listed[0]
assert!(
listed[0]
.start_env() .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(&wayland).ok();
std::fs::remove_dir_all(&x11).ok(); std::fs::remove_dir_all(&x11).ok();

View file

@ -107,7 +107,10 @@ mod tests {
fn defaults_match_design_system() { fn defaults_match_design_system() {
let a = Appearance::default(); let a = Appearance::default();
assert_eq!(a.background.mode, BackgroundMode::Color); 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.format, "%H:%M");
assert_eq!(a.clock.date_format, "%A · %b %d"); assert_eq!(a.clock.date_format, "%A · %b %d");
assert_eq!(a.font.family, "Varela Round"); assert_eq!(a.font.family, "Varela Round");

View file

@ -213,7 +213,14 @@ impl TextRenderer {
origin_y: f32, origin_y: f32,
) { ) {
self.draw_line_weighted( 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). // out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa).
let da = dst.alpha() as u32; let da = dst.alpha() as u32;
let out_a = (sa + da * (255 - sa) / 255) as u8; let out_a = (sa + da * (255 - sa) / 255) as u8;
let out_c = |c: u8, dc: u8| -> u8 { let out_c =
(c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8 |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( if let Some(blended) = PremultipliedColorU8::from_rgba(
out_c(r, dst.red()), out_c(r, dst.red()),
out_c(g, dst.green()), out_c(g, dst.green()),
@ -386,7 +392,10 @@ mod tests {
0.0, 0.0,
); );
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap(); 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 faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
let mut low = Pixmap::new(200, 40).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 // Full-coverage glyph cores are legitimately opaque, but the AA
// edges must carry real intermediate alphas — the old forced-255 // edges must carry real intermediate alphas — the old forced-255
// blend made *every* drawn pixel (edges included) fully opaque. // blend made *every* drawn pixel (edges included) fully opaque.
let has_edge = t let has_edge = t.pixels().iter().any(|p| p.alpha() > 0 && p.alpha() < 255);
.pixels()
.iter()
.any(|p| p.alpha() > 0 && p.alpha() < 255);
assert!( assert!(
has_edge, has_edge,
"glyph AA edges must keep intermediate alphas onto a transparent pixmap" "glyph AA edges must keep intermediate alphas onto a transparent pixmap"

View file

@ -56,9 +56,25 @@ pub fn spawn_check(
generation: u64, generation: u64,
result_tx: Sender<AuthOutcome>, 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 || { std::thread::spawn(move || {
let (done_tx, done_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { 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 result = pam::check(&username, &password);
let _ = done_tx.send(result); let _ = done_tx.send(result);
}); });
@ -75,3 +91,66 @@ pub fn spawn_check(
let _ = result_tx.send((generation, result)); 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] #[test]
fn cstr_to_username_copies_nul_terminated_name() { fn cstr_to_username_copies_nul_terminated_name() {
let raw = CString::new("breadway").unwrap(); let raw = CString::new("breadway").unwrap();
assert_eq!( assert_eq!(cstr_to_username(raw.as_ptr()), Some("breadway".to_string()));
cstr_to_username(raw.as_ptr()),
Some("breadway".to_string())
);
} }
#[test] #[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; let di = (drow + col) * 4;
// SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds); // SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds);
// di + 4 < target size; rows in bounds per above. // 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. // Last column of this row: clamp x1.
let i00 = (r0 + ix + tw - 1) * 4; 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 i10 = (r1 + ix + col) * 4;
let di = (drow + col) * 4; let di = (drow + col) * 4;
// SAFETY: in bounds as in the interior loop. // 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). // Last column of the last row (both clamps).
let i00 = (r0 + ix + tw - 1) * 4; let i00 = (r0 + ix + tw - 1) * 4;
@ -339,7 +367,12 @@ impl Background {
-scaled.pan_y * (0.5 + 0.5 * phase.cos()), -scaled.pan_y * (0.5 + 0.5 * phase.cos()),
) )
} else { } 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 // The cached pixmap is already output-sized, so this per-frame
// draw is a 1:1 copy with at most a translation. `draw_pixmap` // 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); 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] #[test]
fn ken_burns_pan_never_exposes_edges() { fn ken_burns_pan_never_exposes_edges() {
// A small solid-color image panned through a full cycle must cover // A small solid-color image panned through a full cycle must cover
@ -424,7 +500,10 @@ mod tests {
for i in 0..90 { for i in 0..90 {
bg.paint(&mut target, i as f32, true); bg.paint(&mut target, i as f32, true);
assert!( 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" "frame {i} exposed an edge"
); );
} }
@ -461,7 +540,86 @@ mod tests {
}); });
let mut target = Pixmap::new(60, 30).unwrap(); let mut target = Pixmap::new(60, 30).unwrap();
bg.paint(&mut target, 0.0, true); 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] #[test]

View file

@ -97,13 +97,24 @@ impl Default for Scene {
/// (image background + Ken Burns, full chrome) in a loop and prints per-frame /// (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. /// timings, so the software renderer's cost can be measured without Wayland.
fn bench(args: &[String]) { 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 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]) (v[0], v[1])
}; };
let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120); 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 palette = theme::load_palette();
let bg_cfg = breadlock_ui::config::Background { let bg_cfg = breadlock_ui::config::Background {
@ -125,7 +136,8 @@ fn bench(args: &[String]) {
font_family: FONT, font_family: FONT,
clock_text: "12:34", clock_text: "12:34",
date_text: "Friday · Aug 21", date_text: "Friday · Aug 21",
clock_old: None, password_len: 6, clock_old: None,
password_len: 6,
password: "hunter2", password: "hunter2",
reveal: false, reveal: false,
caps_lock: false, caps_lock: false,
@ -157,7 +169,10 @@ fn bench(args: &[String]) {
} }
bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap()); bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
let avg: f64 = bg_times.iter().sum::<f64>() / bg_times.len() as f64; 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); 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"); std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
let palette = theme::load_palette(); let palette = theme::load_palette();
let background = background::Background::load( let background =
&breadlock_ui::config::Background::default(), background::Background::load(&breadlock_ui::config::Background::default(), &palette);
&palette,
);
let scenes = [ let scenes = [
// ---- Staggered entrance: clock leads, pill pops in last (overshoot). // ---- Staggered entrance: clock leads, pill pops in last (overshoot).
Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() }, Scene {
Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() }, name: "01-appear-start",
Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() }, 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. // ---- 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). // ---- 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. // ---- 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). // ---- 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 // ---- Checking: status mid slide-in. Live submit() zeros the secret
// so password_len is 0 — don't fake a filled pill here. // 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). // ---- 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. // ---- 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). // ---- 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. // ---- 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. // ---- 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. // ---- 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. // ---- 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). // ---- 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. // ---- 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. // ---- 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(); 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 /// this binary, no args. The child is reaped on a background thread so
/// a later unlock cannot leave a zombie under `breadlock listen`. /// a later unlock cannot leave a zombie under `breadlock listen`.
pub fn start_locker() -> Result<(), String> { 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 exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
let mut child = Command::new(exe) let mut child = Command::new(exe)
.stdin(Stdio::null()) .stdin(Stdio::null())

View file

@ -103,7 +103,11 @@ pub struct wl_egl_window {
#[link(name = "wayland-egl")] #[link(name = "wayland-egl")]
extern "C" { 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_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32);
fn wl_egl_window_destroy(window: *mut wl_egl_window); fn wl_egl_window_destroy(window: *mut wl_egl_window);
} }
@ -236,10 +240,16 @@ impl GpuRenderer {
egl.initialize(display).ok()?; egl.initialize(display).ok()?;
egl.bind_api(egl::OPENGL_ES_API).ok()?; egl.bind_api(egl::OPENGL_ES_API).ok()?;
let mut configs = Vec::with_capacity(1); 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 config = *configs.first()?;
let context = egl 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()?; .ok()?;
// A 1x1 pbuffer is enough to make the context current for setup // A 1x1 pbuffer is enough to make the context current for setup
// before any real lock surface exists. The chosen config is // before any real lock surface exists. The chosen config is
@ -298,7 +308,9 @@ impl GpuRenderer {
let wallpaper = match &bg_cfg.mode { let wallpaper = match &bg_cfg.mode {
BackgroundMode::Color => None, BackgroundMode::Color => None,
BackgroundMode::Image if bg_cfg.path.is_empty() => { 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 None
} }
BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) { BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) {
@ -324,9 +336,21 @@ impl GpuRenderer {
glow::TEXTURE_MIN_FILTER, glow::TEXTURE_MIN_FILTER,
glow::LINEAR_MIPMAP_LINEAR as i32, 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(
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); glow::TEXTURE_2D,
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); 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 { Some(Wallpaper {
tex, tex,
@ -356,8 +380,16 @@ impl GpuRenderer {
glow::UNSIGNED_BYTE, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])), 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(
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as 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); 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 // Resolve all uniform locations up front, then drop the closure so
// `gl` can move into the renderer. // `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) }; 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_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_tex"), loc(chrome_program, "u_tex")],
loc(bg_program, "u_color"), loc(bg_program, "u_color"),
loc(bg_program, "u_dim_top"), loc(bg_program, "u_dim_top"),
@ -408,7 +456,12 @@ impl GpuRenderer {
/// Wraps a lock surface's `wl_surface` in an EGL window + surface. /// Wraps a lock surface's `wl_surface` in an EGL window + surface.
/// Called once per surface from its first `configure`. /// 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 // SAFETY: the surface proxy is live (this is called from its
// `configure` handler); the returned window is owned by us. // `configure` handler); the returned window is owned by us.
let egl_window = unsafe { let egl_window = unsafe {
@ -424,8 +477,12 @@ impl GpuRenderer {
} }
// SAFETY: `egl_window` is a valid wl_egl_window native window. // SAFETY: `egl_window` is a valid wl_egl_window native window.
let egl_surface = unsafe { let egl_surface = unsafe {
self.egl self.egl.create_window_surface(
.create_window_surface(self.display, self.config, egl_window as *mut c_void, None) self.display,
self.config,
egl_window as *mut c_void,
None,
)
}; };
let egl_surface = match egl_surface { let egl_surface = match egl_surface {
Ok(s) => s, Ok(s) => s,
@ -467,7 +524,12 @@ impl GpuRenderer {
} }
if self if self
.egl .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() .is_err()
{ {
tracing::warn!("eglMakeCurrent failed; skipping GPU frame"); tracing::warn!("eglMakeCurrent failed; skipping GPU frame");
@ -477,7 +539,11 @@ impl GpuRenderer {
unsafe { gl.viewport(0, 0, w as i32, h as i32) }; unsafe { gl.viewport(0, 0, w as i32, h as i32) };
self.draw_background(w, h, inputs); self.draw_background(w, h, inputs);
self.draw_chrome(surface, inputs, text); 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"); tracing::warn!("eglSwapBuffers failed; skipping GPU frame");
return false; return false;
} }
@ -505,7 +571,11 @@ impl GpuRenderer {
wf, 0.0, wf, hf, 0.0, hf, wf, 0.0, wf, hf, 0.0, hf,
]; ];
gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); 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() { if let Some(loc) = self.u_screen[0].as_ref() {
gl.uniform_2_f32(Some(loc), wf, hf); 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 (w, h) = (surface.width, surface.height);
let dirty = surface let dirty = surface
.chrome_pixmap .chrome_pixmap
@ -686,7 +761,11 @@ impl GpuRenderer {
xf0, yf0, xf1, yf0, xf0, yf1, // xf0, yf0, xf1, yf0, xf0, yf1, //
xf1, yf0, xf1, yf1, 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() { if let Some(loc) = self.u_screen[1].as_ref() {
gl.uniform_2_f32(Some(loc), wf, hf); 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 /// Visible source region of the wallpaper for the current pan phase — the
/// same cover-fit + Ken Burns math as `background.rs`. /// 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 (sw, sh) = (wp.0 as f32, wp.1 as f32);
let (tw, th) = (target.0 as f32, target.1 as f32); let (tw, th) = (target.0 as f32, target.1 as f32);
let cover = (tw / sw).max(th / sh); 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()), -pan_y * (0.5 + 0.5 * phase.cos()),
) )
} else { } 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) (-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. /// re-implemented here so the GPU `pan_region` can be checked against it.
/// Software rounds the scaled dims to pixels; GPU keeps floats, so /// Software rounds the scaled dims to pixels; GPU keeps floats, so
/// compare with a 1px tolerance. /// 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 (sw, sh) = (wp.0 as f32, wp.1 as f32);
let (tw, th) = (target.0 as f32, target.1 as f32); let (tw, th) = (target.0 as f32, target.1 as f32);
let cover = (tw / sw).max(th / sh); let cover = (tw / sw).max(th / sh);
@ -833,8 +925,21 @@ mod tests {
let wp = (3840, 2160); let wp = (3840, 2160);
let target = (1920, 1200); let target = (1920, 1200);
let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4); let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4);
assert_eq!(sx, 0.0, "no ken burns: no horizontal pan"); // No ken burns: the visible window is centred in the cover-fit
assert_eq!(sy, 0.0, "no ken burns: no vertical pan"); // 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. // Cover fit: the scaled region covers the target in both axes.
assert!(sw >= 1920.0 && sh >= 1200.0); assert!(sw >= 1920.0 && sh >= 1200.0);
// And it's the tightest cover: at least one axis exactly matches. // 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 pan_x = sw - 1920.0;
let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0); let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0);
let pan_y = (2160.0 * (cover * KENBURNS_ZOOM)).round() - 1200.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!(
assert!((sy0 - pan_y).abs() < 0.5, "at t=0 y should be fully panned (top), got {sy0}"); (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. // Half a period later it has returned to the same spot.
let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S); let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S);
assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01); assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01);
@ -925,8 +1036,15 @@ mod tests {
] ]
.concat(); .concat();
for name in [ for name in [
"u_screen", "u_uv_scale", "u_uv_offset", "u_tex", "u_color", "u_screen",
"u_dim_top", "u_dim_bottom", "u_veil_alpha", "u_screen_h", "u_uv_scale",
"u_uv_offset",
"u_tex",
"u_color",
"u_dim_top",
"u_dim_bottom",
"u_veil_alpha",
"u_screen_h",
] { ] {
assert!( assert!(
declared.iter().any(|d| d == name), declared.iter().any(|d| d == name),
@ -943,7 +1061,8 @@ mod tests {
/// top) exactly, or the GPU veil renders upside-down. /// top) exactly, or the GPU veil renders upside-down.
fn shader_dim_at(frag_y: f32, h: f32) -> f32 { 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 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] #[test]
@ -973,8 +1092,16 @@ mod tests {
#[test] #[test]
fn egl_attribs_are_none_terminated_pairs() { fn egl_attribs_are_none_terminated_pairs() {
assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); assert_eq!(
assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated"); 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_window = false;
let mut saw_es2 = false; let mut saw_es2 = false;
let mut saw_pbuffer = false; let mut saw_pbuffer = false;

View file

@ -170,7 +170,9 @@ fn run_lock() {
let loop_handle = event_loop.handle(); let loop_handle = event_loop.handle();
let auth_result_qh = qh.clone(); 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 { if generation != state.auth_generation {
return; return;
} }
@ -215,7 +217,8 @@ fn run_lock() {
} }
} }
state.redraw_all(&auth_result_qh); state.redraw_all(&auth_result_qh);
}); },
);
// D-Bus status (now-playing / battery): the poller posts snapshots here // 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. // 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)); 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 // Per-element vertical motion: the appear part is uniform, the unlock
// drift is scaled per element for parallax. // 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 // ---- Clock, large, centered in the upper third (size scales with the
// surface). A minute rollover crossfades old text out (drifting up) while // 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() { if let Some(r) = rects.as_deref_mut() {
let old_w = inputs let old_w = inputs
.clock_old .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); .unwrap_or(0.0);
let new_w = text.measure_line_weighted( let new_w = text.measure_line_weighted(
inputs.clock_text, inputs.clock_text,
@ -437,12 +440,18 @@ fn compose_impl(
Weight::BOLD, Weight::BOLD,
); );
let cw = old_w.max(new_w); 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 { match inputs.clock_old {
Some((old, t)) => { Some((old, t)) => {
let t = t.clamp(0.0, 1.0); 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( text.draw_line_weighted(
pixmap, pixmap,
old, old,
@ -494,15 +503,27 @@ fn compose_impl(
// now-playing / battery under that. Info is *not* nested under the date // now-playing / battery under that. Info is *not* nested under the date
// — an empty `date_format` used to hide the status line too. // — 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 date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX);
let (clock_top, clock_height) = let (clock_top, clock_height) = text.measure_box_weighted(
text.measure_box_weighted(inputs.clock_text, inputs.font_family, clock_size, Weight::BOLD); inputs.clock_text,
let mut below_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height 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; + tokens::SPACE_SM as f32;
if !inputs.date_text.is_empty() { if !inputs.date_text.is_empty() {
let date_y = below_y; let date_y = below_y;
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size);
if let Some(r) = rects.as_mut() { 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( text.draw_line(
pixmap, pixmap,
@ -559,7 +580,11 @@ fn compose_impl(
let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t; let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t;
let base_pill = if inputs.failed { 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 { } else {
surface_color surface_color
}; };
@ -570,8 +595,16 @@ fn compose_impl(
}; };
// The pill scales about its center (ease-out-back overshoot) instead of // The pill scales about its center (ease-out-back overshoot) instead of
// rising like the text; while unlocking it stays at rest scale. // 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 scale = if inputs.unlock_t > 0.0 {
let shake_x = if inputs.failed { damped_shake_x(inputs.failed_t) } else { 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 cx = pill_x + pill_w / 2.0;
let cy = pill_y + pill_h / 2.0; let cy = pill_y + pill_h / 2.0;
let pill_xf = Transform::from_row( let pill_xf = Transform::from_row(
@ -603,9 +636,13 @@ fn compose_impl(
); );
} }
if let Some(path) = if let Some(path) = rounded_rect(
rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) pill_x,
{ pill_y,
pill_w,
pill_h,
tokens::RADIUS_SECONDARY as f32,
) {
// Soft drop shadow first (under the fill): concentric expanded copies // Soft drop shadow first (under the fill): concentric expanded copies
// offset downward at fading alpha. The idle breath scales the glow. // offset downward at fading alpha. The idle breath scales the glow.
for (grow, alpha) in PILL_SHADOW { for (grow, alpha) in PILL_SHADOW {
@ -632,13 +669,7 @@ fn compose_impl(
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_color(faded(pill_color, pill_alpha)); paint.set_color(faded(pill_color, pill_alpha));
paint.anti_alias = true; paint.anti_alias = true;
pixmap.fill_path( pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
&path,
&paint,
tiny_skia::FillRule::Winding,
pill_xf,
None,
);
// Hairline border for depth — dropped on the wrong/success states // Hairline border for depth — dropped on the wrong/success states
// (the sketch sets `border-color: transparent` there). // (the sketch sets `border-color: transparent` there).
@ -663,7 +694,10 @@ fn compose_impl(
..Default::default() ..Default::default()
}; };
let mut paint = Paint::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); 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)); label.push_str(&format!("Layout {}", inputs.layout_index + 1));
} }
let chip_size = tokens::FONT_SIZE_SECONDARY as f32; 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_h = chip_size * 1.9;
let chip_x = (w - chip_w) / 2.0; let chip_x = (w - chip_w) / 2.0;
// Clear of the pill: chip bottom sits a full SPACE_LG above the pill // 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. // Slightly lifted surface color so it reads as a separate chip.
paint.set_color(faded(surface_color, chip_alpha)); paint.set_color(faded(surface_color, chip_alpha));
paint.anti_alias = true; 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 { let stroke = tiny_skia::Stroke {
width: 1.0, width: 1.0,
..Default::default() ..Default::default()
@ -808,13 +849,7 @@ fn compose_impl(
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_color(faded(dot_color, pill_alpha)); paint.set_color(faded(dot_color, pill_alpha));
paint.anti_alias = true; paint.anti_alias = true;
pixmap.fill_path( pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
&path,
&paint,
tiny_skia::FillRule::Winding,
pill_xf,
None,
);
} }
} }
} }
@ -859,23 +894,12 @@ fn compose_impl(
accent_color accent_color
}; };
let caret_h = pill_h * 0.5; let caret_h = pill_h * 0.5;
if let Some(path) = rounded_rect( if let Some(path) = rounded_rect(caret_x, dot_y - caret_h / 2.0, CARET_W, caret_h, 1.0)
caret_x, {
dot_y - caret_h / 2.0,
CARET_W,
caret_h,
1.0,
) {
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_color(faded(caret_color, pill_alpha)); paint.set_color(faded(caret_color, pill_alpha));
paint.anti_alias = true; paint.anti_alias = true;
pixmap.fill_path( pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
&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_anim = ease_out_cubic(inputs.status_t);
let status_alpha = status_e * fade * status_anim; let status_alpha = status_e * fade * status_anim;
let color = if inputs.failed { red_color } else { on_surface }; 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); + STATUS_SLIDE_PX * (1.0 - status_anim);
if let Some(r) = rects.as_mut() { 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( text.draw_line(
pixmap, pixmap,
@ -968,7 +1000,12 @@ mod tests {
let top = px[0]; let top = px[0];
let bottom = px[2 * 3]; let bottom = px[2 * 3];
// DIM_ALPHA_TOP (0.34) > DIM_ALPHA_BOTTOM (0.16): top row darker. // 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. // White at top dim 0.34 → 255 * (1 - 0.34) = 168.
assert_eq!(top.red(), 168); assert_eq!(top.red(), 168);
// Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205. // 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 palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
// Wrong-password shake mid-flight. // 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"); let failed_px = compose(&mut text, &failed).expect("failed compose");
assert_eq!((failed_px.width(), failed_px.height()), (400, 300)); assert_eq!((failed_px.width(), failed_px.height()), (400, 300));
assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0)); assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0));
// Success flash phase of the unlock: still fully opaque chrome (flash // Success flash phase of the unlock: still fully opaque chrome (flash
// holds rest pose), not already faded. // 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 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"); let rest_px = compose(&mut text, &rest).expect("rest compose");
// Flash frame should not be a near-empty fade — plenty of chrome left. // 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(); 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}" "success flash should keep chrome visible, lit {flash_lit} vs rest {rest_lit}"
); );
// Fully faded unlock returns just the background. // 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"); let done_px = compose(&mut text, &done).expect("done compose");
assert_eq!((done_px.width(), done_px.height()), (400, 300)); assert_eq!((done_px.width(), done_px.height()), (400, 300));
} }
@ -1130,11 +1211,17 @@ mod tests {
fn reveal_fit_truncates_long_passwords() { fn reveal_fit_truncates_long_passwords() {
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
// Short password fits unchanged. // 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. // A very long one is trimmed and ends with an ellipsis.
let long = "a".repeat(200); let long = "a".repeat(200);
let fitted = ellipsize(&mut text, &long, "sans-serif", 14.0, 60.0); 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()); assert!(fitted.len() < long.len());
// And it actually fits the budget. // And it actually fits the budget.
assert!(text.measure_line(&fitted, "sans-serif", 14.0) <= 60.0); assert!(text.measure_line(&fitted, "sans-serif", 14.0) <= 60.0);
@ -1145,7 +1232,18 @@ mod tests {
let bg = Background::Color(Color::BLACK); let bg = Background::Color(Color::BLACK);
let palette = breadlock_ui::theme::Palette::default(); let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); 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.caps_lock = true;
base.password = "hunter2"; base.password = "hunter2";
// Caps chip visible, no reveal: dots path. // 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. // 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_rest, y_rest) = overlay_motion(1.0, 0.0);
let (a_flash, y_flash) = overlay_motion(1.0, FLASH_FRAC * 0.5); 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!(
assert!((y_flash - y_rest).abs() < 1e-6, "flash must not drift chrome, got {y_flash}"); (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. // After the flash, fade/drift begin.
let (a_fade, y_fade) = overlay_motion(1.0, (FLASH_FRAC + 1.0) * 0.5); 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}"); 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 palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
let mut pixmap = Pixmap::new(400, 300).unwrap(); 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); let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
assert!( assert!(
rect.x1 > rect.x0 && rect.y1 > rect.y0, rect.x1 > rect.x0 && rect.y1 > rect.y0,
"chrome rect must be non-empty, got {rect:?}" "chrome rect must be non-empty, got {rect:?}"
); );
// Clock sits at h*0.28 with glyph height ~ clock_size (400*0.075=30). // 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. // 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. // 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 // 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. // never leaves 0, and 0 < 200 && x1 > 200 still holds). Fail that.
assert!( assert!(
@ -1276,9 +1400,23 @@ mod tests {
let mut pixmap = Pixmap::new(400, 300).unwrap(); let mut pixmap = Pixmap::new(400, 300).unwrap();
// unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible) // unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible)
// chrome so the GPU dirty rect is valid from the first frame. // 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); 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!( assert!(
pixmap.pixels().iter().all(|p| p.alpha() == 0), pixmap.pixels().iter().all(|p| p.alpha() == 0),
"finished unlock must leave the pixmap transparent" "finished unlock must leave the pixmap transparent"
@ -1291,7 +1429,18 @@ mod tests {
let palette = breadlock_ui::theme::Palette::default(); let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
let mut pixmap = Pixmap::new(400, 300).unwrap(); 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); let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
assert!( assert!(
!rect.is_empty() && rect.x0 > 0.0 && rect.y0 > 0.0, !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 palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
let mut pixmap = Pixmap::new(400, 300).unwrap(); 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 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); let r1 = compose_chrome(&mut pixmap, &mut text, &mid);
assert!( assert!(
(r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5, (r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5,
@ -1328,7 +1499,11 @@ mod tests {
n += 1.0; 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(); let dx = (centroid_x(&shaken_px) - centroid_x(&rest_px)).abs();
assert!( assert!(
@ -1369,7 +1544,18 @@ mod tests {
let palette = breadlock_ui::theme::Palette::default(); let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); let mut text = TextRenderer::new();
let mut pixmap = Pixmap::new(400, 300).unwrap(); 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"); with_status.status_text = Some("Wrong password");
let rect = compose_chrome(&mut pixmap, &mut text, &with_status); 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), // 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 bg = Background::Color(Color::from_rgba8(40, 60, 80, 255));
let palette = breadlock_ui::theme::Palette::default(); let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new(); 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. // Full single-pass compose.
let full = compose(&mut text, &inputs).unwrap(); let full = compose(&mut text, &inputs).unwrap();
// Split: dim the background, then composite the chrome over it. // Split: dim the background, then composite the chrome over it.
let mut split = Pixmap::new(400, 300).unwrap(); 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); let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
if veil_alpha > 0.0 { if veil_alpha > 0.0 {
dim_rows(&mut split, veil_alpha); dim_rows(&mut split, veil_alpha);
@ -1430,7 +1629,8 @@ mod tests {
.iter() .iter()
.zip(full.pixels()) .zip(full.pixels())
.map(|(a, b)| { .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.green() as i32 - b.green() as i32).abs())
.max((a.blue() as i32 - b.blue() as i32).abs()) .max((a.blue() as i32 - b.blue() as i32).abs())
.max((a.alpha() as i32 - b.alpha() 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(|_| ())?; .map_err(|_| ())?;
let mut paused: Option<String> = None; 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 { let Some((status, title, artist)) = read_player(conn, name) else {
continue; continue;
}; };
@ -160,7 +163,10 @@ fn read_player(
.to_string(); .to_string();
let mut title = None; let mut title = None;
let mut artist = 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 title = metadata
.get::<&str, &str>(&"xesam:title") .get::<&str, &str>(&"xesam:title")
.ok() .ok()
@ -194,12 +200,8 @@ fn read_player(
/// capped; a Playing player with neither still yields the player name (or /// capped; a Playing player with neither still yields the player name (or
/// `"Playing"`) so it is not outranked by a later titled Paused player. /// `"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 { fn format_now_playing(title: Option<&str>, artist: Option<&str>, player: &str) -> String {
let title = title let title = title.map(sanitize_mpris_field).filter(|s| !s.is_empty());
.map(sanitize_mpris_field) let artist = artist.map(sanitize_mpris_field).filter(|s| !s.is_empty());
.filter(|s| !s.is_empty());
let artist = artist
.map(sanitize_mpris_field)
.filter(|s| !s.is_empty());
let line = match (title, artist) { let line = match (title, artist) {
(Some(t), Some(a)) => format!("{t}{a}"), (Some(t), Some(a)) => format!("{t}{a}"),
(Some(t), None) => t, (Some(t), None) => t,
@ -253,7 +255,11 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
"GetDisplayDevice", "GetDisplayDevice",
&(), &(),
) )
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedObjectPath>()) .and_then(|reply| {
reply
.body()
.deserialize::<zbus::zvariant::OwnedObjectPath>()
})
.map_err(|_| ())?; .map_err(|_| ())?;
let props = conn let props = conn
.call_method( .call_method(
@ -263,11 +269,7 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
"GetAll", "GetAll",
&("org.freedesktop.UPower.Device",), &("org.freedesktop.UPower.Device",),
) )
.and_then(|reply| { .and_then(|reply| reply.body().deserialize::<HashMap<String, OwnedValue>>())
reply
.body()
.deserialize::<HashMap<String, OwnedValue>>()
})
.map_err(|_| ())?; .map_err(|_| ())?;
// DisplayDevice always exists; without a battery IsPresent is false // DisplayDevice always exists; without a battery IsPresent is false
// and Percentage is often 0. Missing IsPresent is treated as absent. // and Percentage is often 0. Missing IsPresent is treated as absent.