Initial commit
This commit is contained in:
commit
9f97f2c989
37 changed files with 4871 additions and 0 deletions
20
breadlock-ui/Cargo.toml
Normal file
20
breadlock-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "breadlock-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||
|
||||
[dependencies]
|
||||
bread-theme.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
# Software-rendering primitives (breadlock only — breadgreet draws via GTK/CSS
|
||||
# instead, so it builds without pulling these in).
|
||||
tiny-skia = { version = "0.12", optional = true }
|
||||
cosmic-text = { version = "0.14", optional = true }
|
||||
chrono = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
paint = ["dep:tiny-skia", "dep:cosmic-text", "dep:chrono"]
|
||||
111
breadlock-ui/src/config.rs
Normal file
111
breadlock-ui/src/config.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// Appearance settings shared by `breadlock.toml` and `breadgreet.toml`.
|
||||
/// `breadgreet` embeds this and adds its own `[sessions]` table.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Appearance {
|
||||
pub background: Background,
|
||||
pub clock: Clock,
|
||||
pub font: Font,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BackgroundMode {
|
||||
Color,
|
||||
Image,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Background {
|
||||
pub mode: BackgroundMode,
|
||||
pub path: String,
|
||||
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
|
||||
/// static color or image background.
|
||||
pub blur: bool,
|
||||
}
|
||||
|
||||
impl Default for Background {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: BackgroundMode::Color,
|
||||
path: String::new(),
|
||||
blur: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Clock {
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
impl Default for Clock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: "%H:%M".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Font {
|
||||
pub family: String,
|
||||
}
|
||||
|
||||
impl Default for Font {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
family: bread_theme::tokens::FONT_FAMILY
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap_or("Varela Round")
|
||||
.trim()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and parses a TOML config file, falling back to `T::default()` if the
|
||||
/// file is missing or malformed — every bread* app runs with sensible
|
||||
/// defaults and no required config.
|
||||
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_system() {
|
||||
let a = Appearance::default();
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
assert_eq!(a.clock.format, "%H:%M");
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_falls_back_to_default() {
|
||||
let a: Appearance = load_or_default(Path::new("/nonexistent/breadlock-test.toml"));
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_partial_toml_with_defaults_for_rest() {
|
||||
let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml");
|
||||
std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
|
||||
let a: Appearance = load_or_default(&dir);
|
||||
assert_eq!(a.clock.format, "%I:%M %p");
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
}
|
||||
134
breadlock-ui/src/desktop_entry.rs
Normal file
134
breadlock-ui/src/desktop_entry.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover
|
||||
//! session launchers (`Name=`, `Exec=`, `Type=`) under
|
||||
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships
|
||||
//! one session today, so this deliberately doesn't handle the full spec
|
||||
//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) —
|
||||
//! only the three keys a greeter needs to list and launch a session.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DesktopEntry {
|
||||
pub name: String,
|
||||
pub exec: String,
|
||||
pub entry_type: String,
|
||||
}
|
||||
|
||||
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
|
||||
/// Returns `None` if `Name=` or `Exec=` is missing.
|
||||
pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
||||
let mut name = None;
|
||||
let mut exec = None;
|
||||
let mut entry_type = None;
|
||||
let mut in_desktop_entry = false;
|
||||
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
in_desktop_entry = section == "Desktop Entry";
|
||||
continue;
|
||||
}
|
||||
if !in_desktop_entry {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
match key.trim() {
|
||||
"Name" => name = Some(value.trim().to_string()),
|
||||
"Exec" => exec = Some(value.trim().to_string()),
|
||||
"Type" => entry_type = Some(value.trim().to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(DesktopEntry {
|
||||
name: name?,
|
||||
exec: exec?,
|
||||
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
|
||||
/// pairs. Unreadable directories and unparsable entries are silently skipped
|
||||
/// — a missing session directory is normal (e.g. no X11 sessions installed).
|
||||
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut entries: Vec<(String, DesktopEntry)> = read_dir
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "desktop"))
|
||||
.filter_map(|e| {
|
||||
let stem = e.path().file_stem()?.to_str()?.to_string();
|
||||
let contents = std::fs::read_to_string(e.path()).ok()?;
|
||||
Some((stem, parse(&contents)?))
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HYPRLAND_DESKTOP: &str = "[Desktop Entry]\n\
|
||||
Name=Hyprland\n\
|
||||
Comment=An intelligent dynamic tiling Wayland compositor\n\
|
||||
Exec=Hyprland\n\
|
||||
Type=Application\n";
|
||||
|
||||
#[test]
|
||||
fn parses_name_exec_type() {
|
||||
let e = parse(HYPRLAND_DESKTOP).unwrap();
|
||||
assert_eq!(e.name, "Hyprland");
|
||||
assert_eq!(e.exec, "Hyprland");
|
||||
assert_eq!(e.entry_type, "Application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_keys_outside_desktop_entry_section() {
|
||||
let contents = "[Desktop Action foo]\nName=Not this one\n\
|
||||
[Desktop Entry]\nName=Real\nExec=real-cmd\n";
|
||||
let e = parse(contents).unwrap();
|
||||
assert_eq!(e.name, "Real");
|
||||
assert_eq!(e.exec, "real-cmd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exec_returns_none() {
|
||||
assert!(parse("[Desktop Entry]\nName=Broken\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_type_defaults_to_application() {
|
||||
let e = parse("[Desktop Entry]\nName=X\nExec=x\n").unwrap();
|
||||
assert_eq!(e.entry_type, "Application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dir_on_missing_directory_returns_empty() {
|
||||
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dir_finds_and_sorts_desktop_files() {
|
||||
let dir = std::env::temp_dir().join("breadlock-ui-test-sessions");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap();
|
||||
std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
|
||||
std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap();
|
||||
|
||||
let found = scan_dir(&dir);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[0].0, "aaa");
|
||||
assert_eq!(found[1].0, "zzz");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
6
breadlock-ui/src/lib.rs
Normal file
6
breadlock-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod desktop_entry;
|
||||
pub mod theme;
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
pub mod painter;
|
||||
171
breadlock-ui/src/painter.rs
Normal file
171
breadlock-ui/src/painter.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//! Software-rendering primitives shared by `breadlock`'s frame composition:
|
||||
//! rounded-rect paths (radius tokens from [`bread_theme::tokens`]) and text
|
||||
//! layout/rasterization via `cosmic-text`, blitted into a `tiny-skia`
|
||||
//! `Pixmap`. Only linked into `breadlock` — `breadgreet` draws through GTK/CSS
|
||||
//! instead and doesn't need a font-shaping stack.
|
||||
|
||||
pub use bread_theme::tokens;
|
||||
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
|
||||
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
||||
|
||||
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
|
||||
/// half the shorter side (a degenerate radius would otherwise self-intersect).
|
||||
pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path> {
|
||||
let r = radius.max(0.0).min(w / 2.0).min(h / 2.0);
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x + r, y);
|
||||
pb.line_to(x + w - r, y);
|
||||
pb.quad_to(x + w, y, x + w, y + r);
|
||||
pb.line_to(x + w, y + h - r);
|
||||
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||||
pb.line_to(x + r, y + h);
|
||||
pb.quad_to(x, y + h, x, y + h - r);
|
||||
pb.line_to(x, y + r);
|
||||
pb.quad_to(x, y, x + r, y);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Owns the font database and glyph raster cache. Expensive to create
|
||||
/// (`FontSystem::new()` scans installed fonts), so construct once and reuse
|
||||
/// across every frame.
|
||||
pub struct TextRenderer {
|
||||
font_system: FontSystem,
|
||||
swash_cache: SwashCache,
|
||||
}
|
||||
|
||||
impl Default for TextRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
font_system: FontSystem::new(),
|
||||
swash_cache: SwashCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer {
|
||||
let metrics = Metrics::new(size_px, size_px * 1.25);
|
||||
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
||||
buffer.set_size(&mut self.font_system, Some(max_width), Some(size_px * 2.0));
|
||||
let attrs = Attrs::new().family(Family::Name(family));
|
||||
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced);
|
||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let buffer = self.shape_line(text, family, size_px, f32::INFINITY);
|
||||
buffer
|
||||
.layout_runs()
|
||||
.map(|run| run.line_w)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Shapes `text` as a single line in `family` at `size_px` and blits it
|
||||
/// into `pixmap` with its top-left baseline anchor at `(origin_x,
|
||||
/// origin_y)`. Pixels outside `pixmap`'s bounds are silently clipped.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_line(
|
||||
&mut self,
|
||||
pixmap: &mut Pixmap,
|
||||
text: &str,
|
||||
family: &str,
|
||||
size_px: f32,
|
||||
color: tiny_skia::Color,
|
||||
origin_x: f32,
|
||||
origin_y: f32,
|
||||
) {
|
||||
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());
|
||||
|
||||
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
|
||||
let ox = origin_x as i32;
|
||||
let oy = origin_y as i32;
|
||||
buffer.draw(
|
||||
&mut self.font_system,
|
||||
&mut self.swash_cache,
|
||||
text_color,
|
||||
|x, y, _w, _h, glyph_color| {
|
||||
let (px, py) = (ox + x, oy + y);
|
||||
if px < 0 || py < 0 || px >= width || py >= height {
|
||||
return;
|
||||
}
|
||||
let (r, g, b, a) = glyph_color.as_rgba_tuple();
|
||||
if a == 0 {
|
||||
return;
|
||||
}
|
||||
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over an
|
||||
/// **opaque** destination pixel (always true here — the lock screen
|
||||
/// background is painted fully opaque before any text or UI chrome).
|
||||
/// Because the destination alpha is always 255, the blended result is also
|
||||
/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds.
|
||||
fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
|
||||
let idx = (y * pixmap.width() + x) as usize;
|
||||
let pixels = pixmap.pixels_mut();
|
||||
let Some(dst) = pixels.get(idx).copied() else {
|
||||
return;
|
||||
};
|
||||
let a32 = a as u32;
|
||||
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 };
|
||||
let blended = PremultipliedColorU8::from_rgba(
|
||||
mix(r, dst.red()),
|
||||
mix(g, dst.green()),
|
||||
mix(b, dst.blue()),
|
||||
255,
|
||||
);
|
||||
if let Some(blended) = blended {
|
||||
pixels[idx] = blended;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_produces_closed_path() {
|
||||
let path = rounded_rect(0.0, 0.0, 100.0, 40.0, tokens::RADIUS_SECONDARY as f32).unwrap();
|
||||
assert!(!path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_clamps_oversized_radius() {
|
||||
// radius larger than half the shorter side must not panic or produce garbage
|
||||
let path = rounded_rect(0.0, 0.0, 10.0, 10.0, 999.0);
|
||||
assert!(path.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_renderer_draws_without_panicking_on_tiny_pixmap() {
|
||||
let mut pixmap = Pixmap::new(64, 16).unwrap();
|
||||
pixmap.fill(tiny_skia::Color::BLACK);
|
||||
let mut renderer = TextRenderer::new();
|
||||
renderer.draw_line(
|
||||
&mut pixmap,
|
||||
"12:34",
|
||||
"sans-serif",
|
||||
12.0,
|
||||
tiny_skia::Color::WHITE,
|
||||
2.0,
|
||||
2.0,
|
||||
);
|
||||
// No panic and the pixmap remains fully opaque is the property under test —
|
||||
// exact glyph coverage depends on whatever fonts are installed on the CI host.
|
||||
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
|
||||
}
|
||||
}
|
||||
32
breadlock-ui/src/theme.rs
Normal file
32
breadlock-ui/src/theme.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
pub use bread_theme::{ink_on, load_palette, Palette};
|
||||
|
||||
/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input
|
||||
/// (palette slots are always produced by [`bread_theme`], which guarantees
|
||||
/// valid hex, but a user-supplied override in a future config field might not).
|
||||
pub fn parse_hex(hex: &str) -> (u8, u8, u8) {
|
||||
let h = hex.trim_start_matches('#');
|
||||
let byte = |i: usize| u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0);
|
||||
(byte(0), byte(2), byte(4))
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
pub fn tiny_skia_color(hex: &str) -> tiny_skia::Color {
|
||||
let (r, g, b) = parse_hex(hex);
|
||||
tiny_skia::Color::from_rgba8(r, g, b, 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_known_hex() {
|
||||
assert_eq!(parse_hex("#1e1e2e"), (0x1e, 0x1e, 0x2e));
|
||||
assert_eq!(parse_hex("89b4fa"), (0x89, 0xb4, 0xfa));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_hex_falls_back_to_black() {
|
||||
assert_eq!(parse_hex("nope"), (0, 0, 0));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue