diff --git a/README.md b/README.md index 50ed2b0..0d00744 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,14 @@ Two small one-icon pills sit left of the island and two right of it, on every mo Left out on purpose: phone stats (iOS shares nothing Linux can read without extra daemons). +## Lock screen + +`amane ipc call lock` locks the session (ext-session-lock; bind it to a key or an idle daemon yourself). Every monitor shows the wallpaper blurred and darkened, the date with Munich's weather now, a big clock, Hyprland's splash message, what plays with its cover, how far it is and previous/play/next (they work while locked), chips for the next calendar event, the new notifications and the battery, and at the bottom `~/.face`, your name and the password field. The monitors share one field. Enter checks the password with PAM as `login` does; a wrong one lights the field's rim and says so. Each part can be switched off and every size is a setting (`lock.look.*`, `lock.show.*`, the Lock screen page). + +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. + +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 Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart. @@ -185,6 +193,7 @@ src/png.rs a stdlib png writer, for tray icons sent as pixels src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU 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/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written @@ -204,6 +213,7 @@ On the desktop that runs the shell: - PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module) - `cava`, `curl` - the Fira Code font +- PAM (`/etc/pam.d/login`), for the lock screen's password - for the control centre: `iwd`, openrc `openvpn.` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre)) ## Install and run @@ -235,6 +245,7 @@ amane ipc call config set island.pill.width 260 # any setting by its con amane ipc call config set hyprland.decoration.blur.size 3 amane ipc call config reset island.pill.width amane ipc call config status # open= page= errors= search= +amane ipc call lock # lock the session ``` Example Hyprland keybind: diff --git a/matugen/amane.colors b/matugen/amane.colors index 6a802d9..89bce68 100644 --- a/matugen/amane.colors +++ b/matugen/amane.colors @@ -10,3 +10,5 @@ outline={{colors.outline_variant.default.hex}} surface={{colors.surface_container.default.hex}} surface_high={{colors.surface_container_high.default.hex}} surface_low={{colors.surface_container_lowest.default.hex}} +# the picture the colours came from, for the lock screen +wallpaper={{image}} diff --git a/src/config.rs b/src/config.rs index b6a72ce..2566571 100644 --- a/src/config.rs +++ b/src/config.rs @@ -628,6 +628,39 @@ settings! { height: f32 = 700.0, 200..=1400; "Height of the notification card" } } + + lock { + look { + wallpaper: String = String::new(); "The picture behind the lock screen, PNG or JPEG; empty takes the wallpaper the shell's colours come from (the wallpaper line matugen writes into the theme file), else the colours' gradient" + 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" + 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" + date_size: f32 = 22.0, 10..=80; "Size of the date and the weather beside it" + clock_size: f32 = 150.0, 40..=400; "Size of the clock" + clock_font: String = String::new(); "The clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font" + clock_weight: u64 = 600, 100..=900; "The clock's weight, 100 (thin) to 900 (black)" + card_width: f32 = 440.0, 240..=1200; "Width of the music card" + card_height: f32 = 100.0, 60..=300; "Height of the music card" + cover: f32 = 68.0, 24..=200; "Size of the cover on the music card" + chip_height: f32 = 34.0, 20..=80; "Height of the chips under the music: the next event, the new notifications, the battery" + avatar_size: f32 = 96.0, 24..=300; "Size of the avatar; pills.home.avatar is the picture" + field_width: f32 = 300.0, 160..=800; "Width of the password field" + field_height: f32 = 46.0, 28..=100; "Height of the password field" + wrong_color: Color = Color::rgb(255, 105, 97); "Colour of the note after a wrong password" + } + show { + weather: bool = true; "The weather now beside the date, for the home pill's place" + splash: bool = true; "Hyprland's splash message under the clock" + music: bool = true; "What plays, with its cover" + controls: bool = true; "Previous, play/pause and next on the music card; they work while locked" + event: bool = true; "The next event of the home pill's calendar" + notifications: bool = true; "How many notifications came in that you haven't seen yet" + battery: bool = true; "The battery's charge" + } + } } // what config.toml set, by path; a missing or bad setting takes its default. written only through change() @@ -1032,6 +1065,7 @@ pub fn title(id: &str) -> String { match id { "control" => String::from("Control centre"), "pills" => String::from("Side pills"), + "lock" => String::from("Lock screen"), "bar" => String::from("Bar hiding"), "input-capture" => String::from("Input capture"), "opengl" => String::from("OpenGL"), diff --git a/src/control.rs b/src/control.rs index ddf5ef2..fdcd99e 100644 --- a/src/control.rs +++ b/src/control.rs @@ -820,14 +820,14 @@ fn compact(items: Vec>, width: f32) -> Rectangle { } // as wide as battery() draws: the bolt, the body and its nub, the percentage -fn battery_width(charge: &Charge) -> f32 { +pub fn battery_width(charge: &Charge) -> f32 { let bolt = if charge.status == "Charging" { 11.0 + 5.0 } else { 0.0 }; bolt + 24.0 + 1.0 + 1.8 + 5.0 + label_width(&format!("{}%", charge.percent), 11.0, 0.0) } // a battery drawn from rectangles, filled to its charge, with a bolt while it charges -fn battery(charge: &Charge) -> Row { +pub fn battery(charge: &Charge) -> Row { let theme = theme::now(); let low = charge.percent <= 15 && charge.status == "Discharging"; diff --git a/src/edge.rs b/src/edge.rs index be867f5..119833f 100644 --- a/src/edge.rs +++ b/src/edge.rs @@ -162,7 +162,7 @@ fn socket(name: &str) -> Option { } // a question to hyprland, as `hyprctl ` would ask it; empty if it can't be asked -fn hyprctl(request: &str) -> String { +pub fn hyprctl(request: &str) -> String { let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else { return String::new(); }; diff --git a/src/home.rs b/src/home.rs index d73c279..9e83cac 100644 --- a/src/home.rs +++ b/src/home.rs @@ -71,6 +71,17 @@ impl Service for Clock { } } +impl Clock { + // local seconds since 1970, 0 until the first tick + pub fn now(&self) -> i64 { + self.now + } + + pub fn avatar(&self) -> Option<&str> { + self.avatar.as_deref() + } +} + fn now_unix() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64) } @@ -92,7 +103,7 @@ fn home_dir() -> PathBuf { } // a path from the settings, ~/ meaning the home folder -fn expanded(path: &str) -> PathBuf { +pub fn expanded(path: &str) -> PathBuf { match path.strip_prefix("~/") { Some(rest) => home_dir().join(rest), None => PathBuf::from(path), @@ -1049,6 +1060,48 @@ fn month_name(month: u32) -> &'static str { MONTHS[(month as usize + 11) % 12] } +// "Tuesday, 20 October" for local seconds `now` +pub fn date_words(now: i64) -> String { + let days = now.div_euclid(86_400); + let (_, month, day) = parse::civil_from_days(days); + + format!("{}, {day} {}", weekday_name(days), month_name(month)) +} + +// the weather now as its icon and "14° Munich", once it's known +pub fn weather_now() -> Option<(Glyph, String)> { + let forecast = Forecast::read(); + let weather = forecast.weather.as_ref()?; + + Some((sky_glyph(weather.code, weather.is_day), format!("{:.0}° {}", weather.temperature, settings::city()))) +} + +// the soonest event that hasn't ended at local seconds `now`: when ("Tomorrow 09:30"), its title and its calendar's colour +pub fn next_event(now: i64) -> Option<(String, String, Option)> { + let agenda = Agenda::read(); + let entry = agenda.events.iter().filter(|entry| entry.end > now).min_by_key(|entry| entry.start)?; + + let today = now.div_euclid(86_400); + let day = entry.start.div_euclid(86_400).max(today); + + let (_, month, number) = parse::civil_from_days(day); + + let named = match day - today { + 0 => String::from("Today"), + 1 => String::from("Tomorrow"), + 2..=6 => String::from(weekday_name(day)), + _ => format!("{number} {}", month_name(month)), + }; + + let when = match (entry.all_day, entry.start <= now) { + (true, _) => named, + (false, true) => format!("Now until {}", hhmm(entry.end)), + (false, false) => format!("{named} {}", hhmm(entry.start)), + }; + + Some((when, entry.summary.clone(), entry.color)) +} + fn sky_glyph(code: u32, day: bool) -> Glyph { match parse::sky(code) { Sky::Clear if day => Glyph::Sun, @@ -1093,7 +1146,7 @@ pub fn height() -> f32 { 2.0 * layout::pad() + settings::avatar_size() + gap + middle_h() + gap + 1.0 + gap + calendar_h() } -fn name() -> String { +pub fn name() -> String { let user = env::var("USER").unwrap_or_default(); let mut letters = user.chars(); diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..1fd15f9 --- /dev/null +++ b/src/lock.rs @@ -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, +} + +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 { + 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> = 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> = 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> = 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> = 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 { + 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> = 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| { + 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>, inner: f32) -> Box { + 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> { + 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); + } +} diff --git a/src/main.rs b/src/main.rs index 6e9306c..95b420a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod home; mod hypr; mod island; mod last; +mod lock; mod map; mod notices; mod parse; @@ -44,6 +45,8 @@ fn main() { .ipc("tray", tray::command) .ipc("config", settings::command) .ipc("pods", pods::command) + .ipc("lock", lock::command) + .lock(lock::view) .window_per_monitor(edge::spacer) .window_per_monitor(edge::view) .window_per_monitor(island::view)