diff --git a/README.md b/README.md index 0d00744..4e7816e 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ Left out on purpose: phone stats (iOS shares nothing Linux can read without extr The picture is `lock.look.wallpaper`, or else the wallpaper the colours came from: `matugen/amane.colors` writes its path into the theme file (`wallpaper=`), so it's there after the next wallpaper change. Without either, the colours' gradient. Amane reads PNG and JPEG only. +It redraws for the music's progress, the clock and typing, so it's cheap to draw: the backdrop (the gradient, and the veil over the picture, darker toward the corners, `lock.look.vignette`) is one shader, `src/lock.wgsl`, and every icon goes into one canvas. Measured in a preview on one 1080p monitor: 0.8 ms per frame to submit and present (2.4 ms before), the GPU's render engine half as busy. + Killing the shell does not unlock: Hyprland keeps the session locked and shows its own error screen. Keep a second TTY logged in when trying changes to the lock screen. ## Settings @@ -195,7 +197,7 @@ src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml src/lock.rs the lock screen (amane's App::lock), `lock` IPC src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl, glass for glass.wgsl -src/*.wgsl shaders: surface (every surface), ring (the candles), curve (the equalizer's line) +src/*.wgsl shaders: surface (every surface), glass (the glass design), ring (the candles), curve (the equalizer's line), lock (the lock screen's backdrop) src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua src/settings.rs the config window, `config` IPC diff --git a/src/config.rs b/src/config.rs index 2566571..c6c3c35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -635,6 +635,7 @@ settings! { blur: u64 = 6, 0..=40; "How blurred the picture is, in pixels of its small copy; 0 shows it sharp" copy_width: u64 = 320, 64..=1920; "Width of the small copy of the picture that's blurred and stretched over the screen; smaller is softer" dim: f32 = 0.3, 0..=1; "How much the picture is darkened, so the words over it stay readable" + vignette: f32 = 0.35, 0..=1; "How much darker the screen's corners are than its middle" top: f32 = 90.0, 0..=600; "Space above the date" bottom: f32 = 70.0, 0..=600; "Space below the password field" gap: f32 = 16.0, 0..=80; "Space between the lock screen's parts" diff --git a/src/control.rs b/src/control.rs index fdcd99e..e041c93 100644 --- a/src/control.rs +++ b/src/control.rs @@ -828,6 +828,11 @@ pub fn battery_width(charge: &Charge) -> f32 { // a battery drawn from rectangles, filled to its charge, with a bolt while it charges pub fn battery(charge: &Charge) -> Row { + battery_with(charge, true) +} + +// `bolt` false leaves the bolt's place (11 px, first) empty, for a caller drawing its icons into one canvas +pub fn battery_with(charge: &Charge, bolt: bool) -> Row { let theme = theme::now(); let low = charge.percent <= 15 && charge.status == "Discharging"; @@ -849,7 +854,11 @@ pub fn battery(charge: &Charge) -> Row { let mut parts: Vec> = Vec::new(); if charge.status == "Charging" { - parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent))); + if bolt { + parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent))); + } else { + parts.push(Box::new(Rectangle::new().width(11.0).height(11.0))); + } } parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center))); diff --git a/src/lock.rs b/src/lock.rs index e174b16..c0f2b8e 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,12 +1,14 @@ use std::fs; +use std::path::PathBuf; +use std::sync::LazyLock; -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 amane::{Canvas, Center, Color, Column, Full, Image, LayerWindow, Lock, Media, MediaPlayer, Monitor, Pointer, Rectangle, Row, Service, Shape, 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::glyph::{Glyph, glyph_shapes}; use crate::home::{self, Clock}; use crate::island::{self, CLEAR, Island, faded}; use crate::system::Power; @@ -20,6 +22,12 @@ use crate::{control, edge, notices, parse, surface, theme}; * 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) + * + * it redraws for the music's progress, the clock and the password, so it's drawn cheaply: + * the backdrop is one shader (lock.wgsl) instead of a gradient vello paints over the whole + * screen on every frame, the glass is surface.rs's shader, and every icon goes into one + * canvas over it all, as each canvas is a vello pass of its own. so that where each icon + * lands is known, every part has a fixed size */ // what the lock screen looks up once per lock, before it shows @@ -41,6 +49,14 @@ impl Service for Shown { // the password field's name; every monitor's lock screen shows the same one const FIELD: &str = "lock-password"; +static SHADER: LazyLock = LazyLock::new(|| surface::shader_file("lock.wgsl", include_str!("lock.wgsl"))); + +// the icons of one frame, drawn together into one canvas over the screen +type Icons = Vec>; + +// a chip's parts, how wide they are, and the icon the canvas draws at their start +type Chip = (Vec>, f32, Option<(Glyph, f32, Color)>); + // `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() { @@ -88,75 +104,115 @@ pub fn view(_: &Monitor) -> LayerWindow { 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() } +// words in a box `width` wide, from its left, elided past it +fn words(words: &str, size: f32, color: Color, bold: bool, width: f32) -> Rectangle { + let text = Text::new(words).size(size).color(color).elide(); + let text = if bold { text.weight(Weight::Bold) } else { text }; + + Rectangle::new().width(width).height(size * 1.4).align_child(Start, Center).child(text) +} + +// empty room `width` wide, where an icon of the canvas goes or just space +fn room(width: f32) -> Rectangle { + Rectangle::new().width(width).height(1.0) +} + +// `glyph` centred on (x, y) +fn place(icons: &mut Icons, glyph: Glyph, size: f32, color: Color, (x, y): (f32, f32)) { + icons.extend(glyph_shapes(glyph, size, color, 0.0, (x - size / 2.0, y - size / 2.0))); +} + 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 mut icons: Icons = Vec::new(); - 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); + // the top part downward from look::top, the bottom part upward from look::bottom + let upper_y = look::top() + gap; + let (upper, upper_h) = upper(&clock, &shown, width, upper_y, &mut icons); + let middle = middle(clock.now(), width, upper_y + upper_h + gap, &mut icons); - let content = Column::new(children![upper, lower]).width(width).height(height).justify(SpaceBetween).align(Center); + let lower_y = height - look::bottom() - lower_height(); + let lower = lower(&clock, width, lower_y, &mut icons); - Stack::new(children![backdrop(width, height, shown.picture.as_deref()), content]).width(width).height(height) -} + let top = Column::new(children![Rectangle::new().width(1.0).height(look::top()), upper, middle]).gap(gap).align(Center); + let bottom = Column::new(children![lower, Rectangle::new().width(1.0).height(look::bottom())]).align(Center); -// 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 content = Column::new(children![top, bottom]).width(width).height(height).justify(SpaceBetween).align(Center); - let mut layers: Vec> = vec![Box::new(Rectangle::new().width(width).height(height).fill(Gradient::linear(160.0, [(0.0, high), (0.55, middle), (1.0, low)])))]; + let mut layers = backdrop(width, height, shown.picture.as_deref()); - if let Some(path) = picture { - let blur = look::blur() as u32; + layers.push(Box::new(content)); - // 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))); + if !icons.is_empty() { + layers.push(Box::new(Canvas::new().width(width).height(height).shapes(icons))); } - 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 { +// the wallpaper colours' gradient, the picture blurred over it, and a veil over that darker at the edges: lock.wgsl +fn backdrop(width: f32, height: f32, picture: Option<&str>) -> Vec> { + let [high, middle, low] = theme::now().surfaces(); + + let shade = |layer: f32| { + let values = vec![[width, height, 160.0, layer], surface::plain(high), surface::plain(middle), surface::plain(low), [look::dim(), look::vignette(), 0.0, 0.0]]; + + Box::new(Rectangle::new().width(width).height(height).shader(SHADER.as_path()).shader_values(values)) as Box + }; + + let Some(path) = picture else { + return vec![shade(0.0)]; + }; + + 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) + }; + + // the gradient shows while the picture decodes + vec![shade(1.0), Box::new(Rectangle::new().width(width).height(height).fill(image)), shade(2.0)] +} + +// the date with the weather now, the clock, hyprland's splash, from `y` down; and how tall +fn upper(clock: &Clock, shown: &Shown, width: f32, y: f32, icons: &mut Icons) -> (Rectangle, f32) { let theme = theme::now(); let size = look::date_size(); let now = clock.now(); + let inner = look::gap() * 0.4; - let mut date: Vec> = Vec::new(); + // the date, and the weather's icon and words after it + let date = if now > 0 { home::date_words(now) } else { String::new() }; + let date_w = words_width(&date, size); + let date_h = size * 1.4; - if now > 0 { - date.push(Box::new(Text::new(home::date_words(now)).size(size).weight(Weight::Bold).color(theme.text))); - } + let mut row: Vec> = vec![Box::new(words(&date, size, theme.text, true, date_w))]; + let mut row_w = date_w; if show::weather() - && let Some((glyph, words)) = home::weather_now() + && let Some((glyph, said)) = 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 (space, icon, after) = (size * 0.6, size * 1.15, size * 0.3); + let said_w = words_width(&said, size); + let total = date_w + space + icon + after + said_w; + + place(icons, glyph, icon, theme.text, ((width - total) / 2.0 + date_w + space + icon / 2.0, y + date_h / 2.0)); + + row.extend(children![room(space), room(icon), room(after), words(&said, size, theme.text, false, said_w)]); + row_w = total; } let seconds = now.rem_euclid(86_400); @@ -165,43 +221,49 @@ fn upper(clock: &Clock, shown: &Shown, width: f32) -> Column { 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 clock_h = digits.height_in(width); - let mut parts: Vec> = vec![Box::new(Row::new(date).gap(size * 0.4).align(Center)), Box::new(digits)]; + let mut parts: Vec> = vec![ + Box::new(Rectangle::new().width(row_w).height(date_h).child(Row::new(row).align(Center))), + Box::new(Rectangle::new().width(width).height(clock_h).align_child(Center, Center).child(digits)), + ]; + + let mut height = date_h + inner + clock_h; if show::splash() && !shown.splash.is_empty() { let size = size * 0.7; - let words = format!("“{}”", shown.splash); + let said = format!("“{}”", shown.splash); + let said_w = (words_width(&said, size) + size).min(width * 0.7); - // as wide as the words, at most most of the screen: elided text starts at its box's left - let box_w = (words_width(&words, size) + size).min(width * 0.7); - - parts.push(Box::new(Rectangle::new().width(box_w).height(size * 1.6).align_child(Center, Center).child(Text::new(words).size(size).color(theme.dim).elide()))); + parts.push(Box::new(words(&said, size, theme.dim, false, said_w))); + height += inner + size * 1.4; } - Column::new(parts).gap(look::gap() * 0.4).align(Center) + (Rectangle::new().width(width).height(height).align_child(Center, Start).child(Column::new(parts).gap(inner).align(Center)), height) } -// the music, then the chips -fn middle(now: i64) -> Column { +// the music, then the chips, from `y` down +fn middle(now: i64, width: f32, y: f32, icons: &mut Icons) -> Column { + let gap = look::gap() * 0.75; let mut parts: Vec> = Vec::new(); + let mut y = y; if show::music() - && let Some(card) = music() + && let Some(card) = music(width, y, icons) { parts.push(Box::new(card)); + y += look::card_height() + gap; } - let chips = chips(now); - - if !chips.is_empty() { - parts.push(Box::new(Row::new(chips).gap(look::gap() * 0.6).align(Center))); + if let Some(chips) = chips(now, width, y, icons) { + parts.push(Box::new(chips)); } - Column::new(parts).gap(look::gap() * 0.75).align(Center) + Column::new(parts).gap(gap).align(Center) } -// the shown player's song: its cover, title, artist and how far it is, and its buttons -fn music() -> Option { +// the shown player's song at `y`: its cover, title, artist and how far it is, and its buttons +fn music(width: f32, y: f32, icons: &mut Icons) -> Option { let media = Media::read(); let island = Island::read(); let art = Art::read(); @@ -209,24 +271,30 @@ fn music() -> Option { let player = island::shown(&media, island.selected.as_deref())?; - let (width, height) = (look::card_width(), look::card_height()); + let (card_w, card_h) = (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 cover = look::cover().min(card_h - 8.0); + let pad = (card_h - cover) / 2.0; + + // where the card's row starts, and the height every part of it is centred on + let left = (width - card_w) / 2.0 + pad; + let middle = y + card_h / 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)) + place(icons, Glyph::Note, cover * 0.45, theme.accent, (left + cover / 2.0, middle)); + + picture.fill(theme.accent_soft) } 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 (small, big) = (text::title() * 1.9, text::title() * 1.9 * 1.25); + let buttons_w = if show::controls() { 2.0 * small + big + 8.0 + gap } else { 0.0 }; + let words_w = (card_w - 2.0 * pad - cover - gap - buttons_w).max(0.0); let title = if player.title().is_empty() { player.identity() } else { player.title() }; @@ -235,105 +303,131 @@ fn music() -> Option { 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 }; + let said = Column::new(children![words(title, text::title(), theme.text, true, words_w), words(player.artist(), text::body(), theme.dim, false, words_w), track]).gap(6.0); - Rectangle::new().width(words_w).height(size * 1.35).align_child(Start, Center).child(words) - }; + let mut items: Vec> = vec![Box::new(picture), Box::new(said)]; - 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> = vec![Box::new(picture), Box::new(words)]; - - if let Some((buttons, _)) = buttons { - items.push(Box::new(buttons)); + if show::controls() { + items.push(Box::new(controls(player, (left + cover + gap + words_w + gap, middle), small, big, icons))); } - let content = Rectangle::new().width(width).height(height).padding(pad).align_child(Start, Center).child(Row::new(items).gap(gap).align(Center)); + let content = Rectangle::new().width(card_w).height(card_h).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)) + Some(surface::shell(card_w, card_h, layout::radius().min(card_h / 2.0), &theme.surfaces(), 0.0, content)) } -// previous, play or pause, next, and how wide they are -fn controls(player: &MediaPlayer) -> (Row, f32) { +// previous, play or pause, next, from (x, the middle's y), their icons into the canvas +fn controls(player: &MediaPlayer, (x, y): (f32, f32), small: f32, big: f32, icons: &mut Icons) -> Row { 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| { - 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())), + for (glyph, size, centre) in [(Glyph::Previous, small, x + small / 2.0), (middle, big, x + small + 4.0 + big / 2.0), (Glyph::Next, small, x + small + 4.0 + big + 4.0 + small / 2.0)] { + place(icons, glyph, size * 0.42, theme.text, (centre, y)); + } + + let button = |size: f32, fill: Color, pressed: Box| Rectangle::new().width(size).height(size).radius(Full).fill(fill).cursor(Pointer).on_click(move |_| pressed()); + + let (previous, toggle, next) = (player.clone(), player.clone(), player.clone()); + + Row::new(children![ + button(small, CLEAR, Box::new(move || previous.previous())), + button(big, overlays::chip(), Box::new(move || toggle.play_pause())), + button(small, CLEAR, Box::new(move || next.next())), ]) .gap(4.0) - .align(Center); - - (row, 2.0 * small + big + 8.0) + .align(Center) } // a glass chip around `parts`, which are `inner` wide -fn chip(parts: Vec>, inner: f32) -> Box { +fn chip(parts: Vec>, inner: f32) -> Rectangle { 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)) + 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> { +// the next event, the new notifications, the battery at `y`: each its own chip, when there is one +fn chips(now: i64, width: f32, y: f32, icons: &mut Icons) -> Option { let theme = theme::now(); - let icon_size = text::body() * 1.3; - let mut chips = Vec::new(); + let (size, height) = (text::body(), look::chip_height()); + let icon = size * 1.3; + let spacing = look::gap() * 0.6; + + // each chip's parts and inner width, and an icon at the start of its parts + let mut wanted: Vec = Vec::new(); if show::event() && let Some((when, what, color)) = home::next_event(now) { + let said = format!("{when} · {what}"); + let said_w = words_width(&said, size).min(look::card_width() * 0.8); 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)); + wanted.push((children![dot, words(&said, size, theme.text, false, said_w)], 9.0 + 8.0 + said_w, None)); } 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()); + let said = if unread == 1 { String::from("1 new notification") } else { format!("{unread} new notifications") }; + let said_w = words_width(&said, size).min(look::card_width()); - chips.push(chip(children![icon(Glyph::Bell, icon_size, theme.text), words], icon_size + 8.0 + width)); + wanted.push((children![room(icon), words(&said, size, theme.text, false, said_w)], icon + 8.0 + said_w, Some((Glyph::Bell, icon, theme.text)))); } if show::battery() && let Some(charge) = &Power::read().battery { - chips.push(chip(children![control::battery(charge)], control::battery_width(charge))); + let bolt = (charge.status == "Charging").then_some((Glyph::Bolt, 11.0, theme.accent)); + + wanted.push((children![control::battery_with(charge, false)], control::battery_width(charge), bolt)); } - chips + if wanted.is_empty() { + return None; + } + + let total: f32 = wanted.iter().map(|(_, inner, _)| inner + height).sum::() + spacing * (wanted.len() - 1) as f32; + let mut x = (width - total) / 2.0; + + let mut row: Vec> = Vec::new(); + + for (parts, inner, icon) in wanted { + // a chip's parts start half its height in + if let Some((glyph, size, color)) = icon { + place(icons, glyph, size, color, (x + height / 2.0 + size / 2.0, y + height / 2.0)); + } + + row.push(Box::new(chip(parts, inner))); + x += inner + height + spacing; + } + + Some(Row::new(row).gap(spacing).align(Center)) } -// the avatar, the name, the password and what became of the last one -fn lower(clock: &Clock) -> Column { +fn name_height() -> f32 { + text::title() * 1.4 +} + +fn note_height() -> f32 { + text::body() * 1.6 +} + +// how tall the avatar, the name, the password and the note are together +fn lower_height() -> f32 { + let gap = look::gap() * 0.6; + + look::avatar_size() + gap + name_height() + gap + look::field_height() + gap + note_height() +} + +// the avatar, the name, the password and what became of the last one, from `y` down +fn lower(clock: &Clock, width: f32, y: f32, icons: &mut Icons) -> Column { let theme = theme::now(); let lock = Lock::read(); + let gap = look::gap() * 0.6; let size = look::avatar_size(); let avatar = Rectangle::new().width(size).height(size).radius(Full).border(2.0, faded(theme.text, 90)); @@ -344,27 +438,32 @@ fn lower(clock: &Clock) -> Column { 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)), + None => { + place(icons, Glyph::Person, size * 0.54, theme.accent, (width / 2.0, y + size / 2.0)); + + avatar.fill(faded(theme.accent, 60)) + } }; - let name = Text::new(home::name()).size(text::title()).weight(Weight::Bold).color(theme.text); + let name = Rectangle::new().width(width).height(name_height()).align_child(Center, Center).child(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 (field_w, field_h) = (look::field_width(), look::field_height()); + let letters = text::popup(); + let icon = letters * 1.1; + let start = field_h * 0.45; - let input = TextInput::new(FIELD).password().focused().placeholder("Enter password").size(size).color(theme.text).on_submit(submit); + let field_y = y + size + gap + name_height() + gap; - 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); + place(icons, Glyph::Lock, icon, theme.dim, ((width - field_w) / 2.0 + start + icon / 2.0, field_y + field_h / 2.0)); + + let input = TextInput::new(FIELD).password().focused().placeholder("Enter password").size(letters).color(theme.text).on_submit(submit); + + // the lock icon's room, then the input 10 px after it + let inside = Row::new(children![room(start), room(icon), room(10.0), Rectangle::new().width(field_w - 2.0 * start - icon - 10.0).height(field_h).align_child(Start, Center).child(input)]).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 field = surface::shell(field_w, field_h, field_h / 2.0, &theme.surfaces(), lift, Rectangle::new().width(field_w).height(field_h).align_child(Start, Center).child(inside)); let (note, color) = if lock.checking() { ("Checking…", theme.dim) @@ -374,9 +473,9 @@ fn lower(clock: &Clock) -> Column { ("", 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)); + let note = Rectangle::new().width(field_w).height(note_height()).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) + Column::new(children![avatar, name, field, note]).gap(gap).align(Center) } // Enter: the field empties at once, pam gets the password on its own thread diff --git a/src/lock.wgsl b/src/lock.wgsl new file mode 100644 index 0000000..d1ea063 --- /dev/null +++ b/src/lock.wgsl @@ -0,0 +1,36 @@ +// the lock screen's backdrop in one pass: the wallpaper colours' gradient, and over the picture +// a veil that darkens it, more toward the edges. drawn as amane's gradient it was a vello pass +// over the whole screen on every frame (vello keeps no gradient between frames). lock.rs fills +// the values: +// 0: width, height, the gradient's angle in degrees (0 runs bottom to top, 90 left to right), layer +// 1-3: the gradient's colours at 0, 0.55 and 1, plain +// 4: the veil's darkness and the vignette's strength, 0 to 1 +// layer 1 is the gradient (under the picture), 2 the veil (over it), 0 both (no picture) + +@fragment +fn main(@location(0) uv: vec2) -> @location(0) vec4 { + let extent = values[0].xy; + let angle = radians(values[0].z); + let layer = u32(values[0].w); + + // where this pixel lies along the gradient, 0 at its start corner and 1 at its end + let direction = vec2(sin(angle), -cos(angle)); + let reach = abs(direction.x) * extent.x + abs(direction.y) * extent.y; + let t = clamp(dot((uv - 0.5) * extent, direction) / max(reach, 1.0) + 0.5, 0.0, 1.0); + + let gradient = select(mix(values[2], values[3], (t - 0.55) / 0.45), mix(values[1], values[2], t / 0.55), t < 0.55); + + // the veil, darker toward the corners: 0 in the middle, 1 in a corner + let corner = length((uv - 0.5) * 2.0) / 1.4142; + let dark = clamp(values[4].x + (1.0 - values[4].x) * values[4].y * smoothstep(0.3, 1.0, corner), 0.0, 1.0); + + if layer == 1u { + return vec4(gradient.rgb, 1.0); + } + + if layer == 2u { + return vec4(0.0, 0.0, 0.0, dark); + } + + return vec4(gradient.rgb * (1.0 - dark), 1.0); +}