Lock screen (amane ipc call lock): the wallpaper blurred, the date with the weather, a big clock, Hyprland's splash, the music with controls, the next event, new notifications, the battery, ~/.face and the password (PAM); lock.look.* and lock.show.* settings

This commit is contained in:
server2
2026-10-21 19:26:13 +02:00
parent c24671e981
commit e963c944e8
8 changed files with 495 additions and 5 deletions
+387
View File
@@ -0,0 +1,387 @@
use std::fs;
use amane::{Center, Color, Column, Full, Gradient, Image, LayerWindow, Lock, Media, MediaPlayer, Monitor, Pointer, Rectangle, Row, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, children, window_size};
use crate::art::Art;
use crate::config::appearance::{font, overlays, text};
use crate::config::lock::{look, show};
use crate::config::pills::layout;
use crate::glyph::{Glyph, icon};
use crate::home::{self, Clock};
use crate::island::{self, CLEAR, Island, faded};
use crate::system::Power;
use crate::{control, edge, notices, parse, surface, theme};
/*
* the lock screen, amane's App::lock (ext-session-lock; pam checks the password the way the
* `login` service does): the wallpaper blurred under a veil; the date with the weather now,
* a big clock and hyprland's splash; the music with its controls, then chips for the next
* event, the new notifications and the battery; at the bottom the avatar, the name and the
* password. every monitor shows it, all sharing the one password field.
* `amane ipc call lock` locks. killing the shell doesn't unlock: hyprland keeps the session
* locked (README, Lock screen)
*/
// what the lock screen looks up once per lock, before it shows
#[derive(Default)]
pub struct Shown {
splash: String,
picture: Option<String>,
}
impl Service for Shown {
fn new() -> Self {
Self::default()
}
// set by command() only
fn listen() {}
}
// the password field's name; every monitor's lock screen shows the same one
const FIELD: &str = "lock-password";
// `amane ipc call lock`: the splash and the picture are looked up first, so the screen shows them from its first frame
pub fn command(words: &[String]) -> String {
if !words.is_empty() {
return String::from("usage: lock");
}
let splash = edge::hyprctl("splash").trim().to_string();
*Shown::write() = Shown { splash, picture: picture() };
Lock::start();
String::from("locked")
}
// the picture: the setting's, else the wallpaper matugen named in the theme file; only a file that's there
fn picture() -> Option<String> {
let set = look::wallpaper();
let wanted = if set.trim().is_empty() {
let theme = fs::read_to_string(theme::config_file("theme")).unwrap_or_default();
parse::settings(&theme).into_iter().find(|(key, _)| key == "wallpaper").map(|(_, value)| value)?
} else {
set
};
let path = home::expanded(wanted.trim());
path.is_file().then(|| path.to_string_lossy().into_owned())
}
pub fn view(_: &Monitor) -> LayerWindow {
theme::begin();
// a lock screen always covers its whole monitor, whatever size it asks for
let window = LayerWindow::new().width(1.0).height(1.0);
let (width, height) = window_size();
// the compositor hasn't said the size yet
if width <= 0.0 || height <= 0.0 {
return window.child(Rectangle::new().width(0.0).height(0.0));
}
window.child(screen(width, height))
}
fn alpha(share: f32) -> u8 {
(share.clamp(0.0, 1.0) * 255.0).round() as u8
}
// about how wide words are at `size`, as the chips size themselves
fn words_width(words: &str, size: f32) -> f32 {
words.chars().count() as f32 * size * font::letter_width()
}
fn screen(width: f32, height: f32) -> Stack {
let clock = Clock::read();
let shown = Shown::read();
let gap = look::gap();
let spacer = |height: f32| Rectangle::new().width(1.0).height(height);
let upper = Column::new(children![spacer(look::top()), upper(&clock, &shown, width), middle(clock.now())]).gap(gap).align(Center);
let lower = Column::new(children![lower(&clock), spacer(look::bottom())]).align(Center);
let content = Column::new(children![upper, lower]).width(width).height(height).justify(SpaceBetween).align(Center);
Stack::new(children![backdrop(width, height, shown.picture.as_deref()), content]).width(width).height(height)
}
// the wallpaper's colours as a gradient, the picture blurred over it, and a veil for the words
fn backdrop(width: f32, height: f32, picture: Option<&str>) -> Stack {
let theme = theme::now();
let [high, middle, low] = theme.surfaces();
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(width).height(height).fill(Gradient::linear(160.0, [(0.0, high), (0.55, middle), (1.0, low)])))];
if let Some(path) = picture {
let blur = look::blur() as u32;
// blurred: a small copy, blurred once when decoded and stretched over the screen, costs nothing per frame
let image = if blur == 0 {
Image::cover(path).thumbnail(width.ceil() as u32, height.ceil() as u32)
} else {
let copy = look::copy_width() as u32;
Image::cover(path).thumbnail(copy, (copy as f32 * height / width).ceil() as u32).blurred(blur)
};
layers.push(Box::new(Rectangle::new().width(width).height(height).fill(image)));
}
layers.push(Box::new(Rectangle::new().width(width).height(height).fill(faded(Color::BLACK, alpha(look::dim())))));
Stack::new(layers).width(width).height(height)
}
// the date with the weather now, the clock, hyprland's splash
fn upper(clock: &Clock, shown: &Shown, width: f32) -> Column {
let theme = theme::now();
let size = look::date_size();
let now = clock.now();
let mut date: Vec<Box<dyn Widget>> = Vec::new();
if now > 0 {
date.push(Box::new(Text::new(home::date_words(now)).size(size).weight(Weight::Bold).color(theme.text)));
}
if show::weather()
&& let Some((glyph, words)) = home::weather_now()
{
date.push(Box::new(Rectangle::new().width(size * 0.4).height(1.0)));
date.push(Box::new(icon(glyph, size * 1.15, theme.text)));
date.push(Box::new(Text::new(words).size(size).color(theme.text)));
}
let seconds = now.rem_euclid(86_400);
let time = if now > 0 { format!("{:02}:{:02}", seconds / 3_600, seconds % 3_600 / 60) } else { String::new() };
let digits = Text::new(time).size(look::clock_size()).weight(look::clock_weight().min(1000) as u16).color(theme.text).tight();
let family = look::clock_font();
let digits = if family.trim().is_empty() { digits } else { digits.font(family.trim()) };
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(Row::new(date).gap(size * 0.4).align(Center)), Box::new(digits)];
if show::splash() && !shown.splash.is_empty() {
let size = text::popup();
parts.push(Box::new(
Rectangle::new().width(width * 0.7).height(size * 1.6).align_child(Center, Center).child(Text::new(format!("“{}”", shown.splash)).size(size).color(theme.dim).elide()),
));
}
Column::new(parts).gap(look::gap() * 0.4).align(Center)
}
// the music, then the chips
fn middle(now: i64) -> Column {
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
if show::music()
&& let Some(card) = music()
{
parts.push(Box::new(card));
}
let chips = chips(now);
if !chips.is_empty() {
parts.push(Box::new(Row::new(chips).gap(look::gap() * 0.6).align(Center)));
}
Column::new(parts).gap(look::gap() * 0.75).align(Center)
}
// the shown player's song: its cover, title, artist and how far it is, and its buttons
fn music() -> Option<Rectangle> {
let media = Media::read();
let island = Island::read();
let art = Art::read();
let theme = theme::now();
let player = island::shown(&media, island.selected.as_deref())?;
let (width, height) = (look::card_width(), look::card_height());
let gap = look::gap() * 0.75;
let cover = look::cover().min(height - 8.0);
let pad = (height - cover) / 2.0;
let picture = Rectangle::new().width(cover).height(cover).radius(cover * 0.18);
let picture = if art.path.is_empty() {
picture.fill(theme.accent_soft).align_child(Center, Center).child(icon(Glyph::Note, cover * 0.45, theme.accent))
} else {
let pixels = (cover * 2.0).ceil() as u32;
picture.fill(Image::cover(art.path.clone()).thumbnail(pixels, pixels))
};
let buttons = show::controls().then(|| controls(player));
let buttons_w = buttons.as_ref().map_or(0.0, |(_, width)| width + gap);
let words_w = (width - 2.0 * pad - cover - gap - buttons_w).max(0.0);
let title = if player.title().is_empty() { player.identity() } else { player.title() };
let length = player.length().as_secs_f32();
let done = if length > 0.0 { (player.position().as_secs_f32() / length).clamp(0.0, 1.0) } else { 0.0 };
let track = Rectangle::new().width(words_w).height(4.0).radius(Full).fill(overlays::track()).align_child(Start, Center).child(Rectangle::new().width((words_w * done).max(4.0)).height(4.0).radius(Full).fill(theme.text));
let line = |words: &str, size: f32, color: Color, bold: bool| {
let words = Text::new(words).size(size).color(color).elide();
let words = if bold { words.weight(Weight::Bold) } else { words };
Rectangle::new().width(words_w).height(size * 1.35).align_child(Start, Center).child(words)
};
let words = Column::new(children![line(title, text::title(), theme.text, true), line(player.artist(), text::body(), theme.dim, false), track]).gap(6.0);
let mut items: Vec<Box<dyn Widget>> = vec![Box::new(picture), Box::new(words)];
if let Some((buttons, _)) = buttons {
items.push(Box::new(buttons));
}
let content = Rectangle::new().width(width).height(height).padding(pad).align_child(Start, Center).child(Row::new(items).gap(gap).align(Center));
Some(surface::shell(width, height, layout::radius().min(height / 2.0), &theme.surfaces(), 0.0, content))
}
// previous, play or pause, next, and how wide they are
fn controls(player: &MediaPlayer) -> (Row, f32) {
let theme = theme::now();
let small = text::title() * 1.9;
let big = small * 1.25;
let button = |glyph: Glyph, size: f32, fill: Color, pressed: Box<dyn Fn()>| {
Rectangle::new().width(size).height(size).radius(Full).fill(fill).align_child(Center, Center).cursor(Pointer).on_click(move |_| pressed()).child(icon(glyph, size * 0.42, theme.text))
};
let (previous, toggle, next) = (player.clone(), player.clone(), player.clone());
let middle = if player.playing() { Glyph::Pause } else { Glyph::Play };
let row = Row::new(children![
button(Glyph::Previous, small, CLEAR, Box::new(move || previous.previous())),
button(middle, big, overlays::chip(), Box::new(move || toggle.play_pause())),
button(Glyph::Next, small, CLEAR, Box::new(move || next.next())),
])
.gap(4.0)
.align(Center);
(row, 2.0 * small + big + 8.0)
}
// a glass chip around `parts`, which are `inner` wide
fn chip(parts: Vec<Box<dyn Widget>>, inner: f32) -> Box<dyn Widget> {
let height = look::chip_height();
let width = inner + height;
let content = Rectangle::new().width(width).height(height).align_child(Center, Center).child(Row::new(parts).gap(8.0).align(Center));
Box::new(surface::shell(width, height, height / 2.0, &theme::now().surfaces(), 0.0, content))
}
// words in a chip, cut with an ellipsis past `most` wide; and how wide they are
fn chip_words(words: &str, most: f32) -> (Rectangle, f32) {
let size = text::body();
let width = words_width(words, size).min(most);
(Rectangle::new().width(width).height(size * 1.4).align_child(Start, Center).child(Text::new(words).size(size).color(theme::now().text).elide()), width)
}
// the next event, the new notifications, the battery: each its own chip, when there is one
fn chips(now: i64) -> Vec<Box<dyn Widget>> {
let theme = theme::now();
let icon_size = text::body() * 1.3;
let mut chips = Vec::new();
if show::event()
&& let Some((when, what, color)) = home::next_event(now)
{
let dot = Rectangle::new().width(9.0).height(9.0).radius(Full).fill(color.unwrap_or(theme.accent));
let (words, width) = chip_words(&format!("{when} · {what}"), look::card_width() * 0.8);
chips.push(chip(children![dot, words], 9.0 + 8.0 + width));
}
let unread = notices::unread();
if show::notifications() && unread > 0 {
let words = if unread == 1 { String::from("1 new notification") } else { format!("{unread} new notifications") };
let (words, width) = chip_words(&words, look::card_width());
chips.push(chip(children![icon(Glyph::Bell, icon_size, theme.text), words], icon_size + 8.0 + width));
}
if show::battery()
&& let Some(charge) = &Power::read().battery
{
chips.push(chip(children![control::battery(charge)], control::battery_width(charge)));
}
chips
}
// the avatar, the name, the password and what became of the last one
fn lower(clock: &Clock) -> Column {
let theme = theme::now();
let lock = Lock::read();
let size = look::avatar_size();
let avatar = Rectangle::new().width(size).height(size).radius(Full).border(2.0, faded(theme.text, 90));
let avatar = match clock.avatar() {
Some(path) => {
let pixels = (size * 2.0).ceil() as u32;
avatar.fill(Image::cover(path).thumbnail(pixels, pixels))
}
None => avatar.fill(faded(theme.accent, 60)).align_child(Center, Center).child(icon(Glyph::Person, size * 0.54, theme.accent)),
};
let name = Text::new(home::name()).size(text::title()).weight(Weight::Bold).color(theme.text);
let (width, height) = (look::field_width(), look::field_height());
let size = text::popup();
let input = TextInput::new(FIELD).password().focused().placeholder("Enter password").size(size).color(theme.text).on_submit(submit);
let inside = Row::new(children![
Rectangle::new().width(height * 0.45).height(1.0),
icon(Glyph::Lock, size * 1.1, theme.dim),
Rectangle::new().width(width - height * 0.9 - size * 1.1 - 10.0).height(height).align_child(Start, Center).child(input),
])
.gap(10.0)
.align(Center);
// a wrong password lights the field's rim
let lift = if lock.failed() { 1.0 } else { 0.0 };
let field = surface::shell(width, height, height / 2.0, &theme.surfaces(), lift, Rectangle::new().width(width).height(height).align_child(Start, Center).child(inside));
let (note, color) = if lock.checking() {
("Checking…", theme.dim)
} else if lock.failed() {
("Wrong password, try again", look::wrong_color())
} else {
("", theme.dim)
};
let note = Rectangle::new().width(width).height(text::body() * 1.6).align_child(Center, Center).child(Text::new(note).size(text::body()).color(color));
Column::new(children![avatar, name, field, note]).gap(look::gap() * 0.6).align(Center)
}
// Enter: the field empties at once, pam gets the password on its own thread
fn submit(password: String) {
TextInput::set_text(FIELD, "");
if !password.is_empty() {
Lock::unlock(&password);
}
}