Breadlock UI update: motion system, depth polish, and new animations

Adds a full motion system to the lock screen (staggered entrance, dot pop
+ caret, wrong-password shake, success flash, clock crossfade), depth
polish (gradient veil, pill shadow + hairline border, "Enter password"
hint, responsive typography), and a second wave of animations (idle
breathing, success dot cascade, status slide-in, parallax unlock drift,
animated checking ellipsis, opt-in Ken Burns wallpaper pan). Includes a
dev-only breadlock-preview harness that renders every state to PNGs with
no Wayland involved, and a design/sketch.html prototype.

Fixes text alpha being dropped by cosmic-text's glyph-mask path (all text
fades now work) and double-drifting that made the date/status overlap the
clock/pill during unlock.
This commit is contained in:
Breadway 2026-08-21 12:28:41 +08:00
parent 647a211a93
commit f0b66cd791
14 changed files with 1771 additions and 84 deletions

View file

@ -26,6 +26,10 @@ pub struct Background {
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
/// static color or image background.
pub blur: bool,
/// Slow Ken Burns pan on image backgrounds (a gentle drift + zoom instead
/// of a static image). CPU cost: the background redraws continuously at a
/// low frame rate while locked, so this is opt-in.
pub ken_burns: bool,
}
impl Default for Background {
@ -34,6 +38,7 @@ impl Default for Background {
mode: BackgroundMode::Color,
path: String::new(),
blur: false,
ken_burns: false,
}
}
}
@ -42,12 +47,16 @@ impl Default for Background {
#[serde(default)]
pub struct Clock {
pub format: String,
/// strftime format for the date line under the clock. Empty string hides
/// the date. `%A` = full weekday, `%b` = abbreviated month, `%d` = day.
pub date_format: String,
}
impl Default for Clock {
fn default() -> Self {
Self {
format: "%H:%M".to_string(),
date_format: "%A · %b %d".to_string(),
}
}
}
@ -89,7 +98,9 @@ mod tests {
fn defaults_match_design_system() {
let a = Appearance::default();
assert_eq!(a.background.mode, BackgroundMode::Color);
assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)");
assert_eq!(a.clock.format, "%H:%M");
assert_eq!(a.clock.date_format, "%A · %b %d");
assert_eq!(a.font.family, "Varela Round");
}

View file

@ -6,6 +6,7 @@
pub use bread_theme::tokens;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
use std::collections::HashMap;
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
@ -32,6 +33,10 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path>
pub struct TextRenderer {
font_system: FontSystem,
swash_cache: SwashCache,
/// Exact glyph-pixel span `(top, height)` per unique `(text, family,
/// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so
/// fractional sizes don't thrash the cache.
boxes: HashMap<(String, String, u32), (f32, f32)>,
}
impl Default for TextRenderer {
@ -45,6 +50,7 @@ impl TextRenderer {
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
boxes: HashMap::new(),
}
}
@ -58,6 +64,47 @@ impl TextRenderer {
buffer
}
/// Exact vertical span `(top, height)` of the glyph pixels a line drawn
/// with [`Self::draw_line`] at `(0, 0)` would occupy: `top` is the
/// distance from the draw origin down to the highest glyph pixel.
/// `draw_line`'s `origin_y` anchors the *top* of the text (not the
/// baseline), so centering a line of height `h` in a box spanning
/// `[y0, y1]` needs `origin_y = y0 + (h - height) / 2 - top`.
///
/// Measured exactly by rendering the line once into a tiny offscreen
/// pixmap and scanning it, then cached — lock-screen text changes rarely
/// (clock per minute, date per day, static hints once), so the one-off
/// cost is negligible and the result is correct for any font.
pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) {
let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32);
if let Some(b) = self.boxes.get(&key) {
return *b;
}
let w = self.measure_line(text, family, size_px).ceil().max(1.0) as u32;
let h = (size_px * 1.5).ceil().max(1.0) as u32;
let mut probe = match Pixmap::new(w, h) {
Some(p) => p,
None => return (0.0, size_px),
};
self.draw_line(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0);
let (mut top, mut bottom) = (h as f32, 0.0f32);
for y in 0..h {
for x in 0..w {
if probe.pixel(x, y).is_some_and(|p| p.alpha() > 0) {
top = top.min(y as f32);
bottom = bottom.max(y as f32);
}
}
}
let boxed = if bottom >= top {
(top, bottom - top + 1.0)
} else {
(0.0, size_px)
};
self.boxes.insert(key, boxed);
boxed
}
/// Width in pixels `text` would occupy if drawn via [`Self::draw_line`]
/// with the same `family`/`size_px` — use to center text before drawing.
pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 {
@ -85,7 +132,13 @@ impl TextRenderer {
let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32);
let c8 = color.to_color_u8();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.alpha());
// cosmic-text's glyph-Mask rendering drops the base color's alpha
// entirely — its swash `with_pixels` uses the glyph coverage as the
// output alpha (see the "TODO: blend base alpha?" in its source), so
// a translucent text color would render fully opaque. Fold the
// requested alpha back in at blend time below; RGB stays straight.
let base_alpha = c8.alpha();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_alpha);
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
let ox = origin_x as i32;
@ -103,6 +156,12 @@ impl TextRenderer {
if a == 0 {
return;
}
// `a` is the glyph coverage; combine it with the requested
// color alpha for the true source alpha.
let a = (a as u32 * base_alpha as u32 / 255) as u8;
if a == 0 {
return;
}
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
},
);
@ -168,4 +227,37 @@ mod tests {
// exact glyph coverage depends on whatever fonts are installed on the CI host.
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
}
#[test]
fn draw_line_respects_color_alpha() {
// Regression: cosmic-text's glyph-Mask path drops the base color's
// alpha (coverage becomes the only alpha), so translucent text used to
// render fully opaque — which broke every text fade on the lock screen
// (clock/date/hint/status never faded during appear/unlock).
let mut renderer = TextRenderer::new();
let mut full = Pixmap::new(200, 40).unwrap();
full.fill(tiny_skia::Color::BLACK);
renderer.draw_line(
&mut full,
"12:34",
"sans-serif",
24.0,
tiny_skia::Color::WHITE,
0.0,
0.0,
);
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(full_max > 200, "full-alpha text should render bright, got {full_max}");
let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
let mut low = Pixmap::new(200, 40).unwrap();
low.fill(tiny_skia::Color::BLACK);
renderer.draw_line(&mut low, "12:34", "sans-serif", 24.0, faint, 0.0, 0.0);
let low_max = low.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(
low_max < 100,
"10%-alpha text must not render near-white, got {low_max}"
);
}
}