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:
parent
647a211a93
commit
f0b66cd791
14 changed files with 1771 additions and 84 deletions
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue