Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
303764732b | ||
|
|
184da49c2f | ||
|
|
e963c944e8 | ||
|
|
c24671e981 |
@@ -124,6 +124,16 @@ 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).
|
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.
|
||||||
|
|
||||||
|
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
|
## 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.
|
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.
|
||||||
@@ -132,7 +142,7 @@ The config window edits the same file: Settings at the foot of the control centr
|
|||||||
|
|
||||||
### Glass
|
### Glass
|
||||||
|
|
||||||
`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow) or `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow), `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow) or `glass` (after Apple's Liquid Glass: a thin tint and an edge like the rim of a lens, brightest where the light falls in at the top left, a glint where it leaves at the bottom right, a thin specular line and a faint warm and cool fringe). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid|glass.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). `glass` has its own, `src/glass.wgsl`, drawn twice: the shadow and tint under the content, the lit edge over it, so a photo on a card lies behind the glass. Unlike Apple's, it doesn't bend the desktop at the rim: a shell's shader never sees what's behind its window, Hyprland's blur is all it has. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||||
|
|
||||||
Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below).
|
Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below).
|
||||||
|
|
||||||
@@ -142,7 +152,7 @@ Amane draws rectangles, borders, letters and pictures as cheap quads, and gradie
|
|||||||
|
|
||||||
`tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory.
|
`tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory.
|
||||||
|
|
||||||
Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner).
|
Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner; glass is its `opacity` all over, 0.32 by default).
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
hl.layer_rule({
|
hl.layer_rule({
|
||||||
@@ -185,8 +195,9 @@ 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/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/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/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml
|
||||||
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl
|
src/lock.rs the lock screen (amane's App::lock), `lock` IPC
|
||||||
src/*.wgsl shaders: surface (every surface), ring (the candles), curve (the equalizer's line)
|
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), 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/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/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
|
||||||
src/settings.rs the config window, `config` IPC
|
src/settings.rs the config window, `config` IPC
|
||||||
@@ -204,6 +215,7 @@ On the desktop that runs the shell:
|
|||||||
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||||||
- `cava`, `curl`
|
- `cava`, `curl`
|
||||||
- the Fira Code font
|
- the Fira Code font
|
||||||
|
- PAM (`/etc/pam.d/login`), for the lock screen's password
|
||||||
- for the control centre: `iwd`, openrc `openvpn.<name>` 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))
|
- for the control centre: `iwd`, openrc `openvpn.<name>` 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
|
## Install and run
|
||||||
@@ -235,6 +247,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 set hyprland.decoration.blur.size 3
|
||||||
amane ipc call config reset island.pill.width
|
amane ipc call config reset island.pill.width
|
||||||
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
|
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
|
||||||
|
amane ipc call lock # lock the session
|
||||||
```
|
```
|
||||||
|
|
||||||
Example Hyprland keybind:
|
Example Hyprland keybind:
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ outline={{colors.outline_variant.default.hex}}
|
|||||||
surface={{colors.surface_container.default.hex}}
|
surface={{colors.surface_container.default.hex}}
|
||||||
surface_high={{colors.surface_container_high.default.hex}}
|
surface_high={{colors.surface_container_high.default.hex}}
|
||||||
surface_low={{colors.surface_container_lowest.default.hex}}
|
surface_low={{colors.surface_container_lowest.default.hex}}
|
||||||
|
# the picture the colours came from, for the lock screen
|
||||||
|
wallpaper={{image}}
|
||||||
|
|||||||
+44
-1
@@ -232,7 +232,7 @@ settings! {
|
|||||||
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
|
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
|
||||||
}
|
}
|
||||||
surface {
|
surface {
|
||||||
design: String ["solid", "frosted", "liquid"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen) or liquid (clear glass with a light-catching edge). See-through ones need Hyprland's blur behind the shell: README, Glass"
|
design: String ["solid", "frosted", "liquid", "glass"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen), liquid (clear glass with a light-catching edge) or glass (after Apple's Liquid Glass: a thin tint with a lens-like rim). See-through ones need Hyprland's blur behind the shell: README, Glass"
|
||||||
}
|
}
|
||||||
solid {
|
solid {
|
||||||
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
|
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
|
||||||
@@ -252,6 +252,14 @@ settings! {
|
|||||||
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
|
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
|
||||||
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
|
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
|
||||||
}
|
}
|
||||||
|
glass {
|
||||||
|
opacity: f32 = 0.32, 0.05..=1.0; "How strongly the glass is tinted in its middle. Keep it above the ignore_alpha of Hyprland's layer rule, or the desktop behind isn't blurred (README, Glass)"
|
||||||
|
edge: f32 = 16.0, 1..=60; "Width of the band along the rim that catches the light like the edge of a lens, in pixels"
|
||||||
|
light: f32 = 0.55, 0.0..=1.0; "Strength of the light caught where it falls in (top left) and where it leaves (bottom right)"
|
||||||
|
rim: f32 = 0.5, 0.0..=1.0; "Strength of the thin bright line on the rim"
|
||||||
|
dispersion: f32 = 0.3, 0.0..=1.0; "Strength of the warm and cool fringe along the rim, as glass splits light"
|
||||||
|
shadow: f32 = 0.18, 0.0..=1.0; "Strength of the soft shadow under each surface"
|
||||||
|
}
|
||||||
fallback {
|
fallback {
|
||||||
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
|
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
|
||||||
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
|
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
|
||||||
@@ -620,6 +628,40 @@ settings! {
|
|||||||
height: f32 = 700.0, 200..=1400; "Height of the notification card"
|
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"
|
||||||
|
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"
|
||||||
|
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()
|
// what config.toml set, by path; a missing or bad setting takes its default. written only through change()
|
||||||
@@ -1024,6 +1066,7 @@ pub fn title(id: &str) -> String {
|
|||||||
match id {
|
match id {
|
||||||
"control" => String::from("Control centre"),
|
"control" => String::from("Control centre"),
|
||||||
"pills" => String::from("Side pills"),
|
"pills" => String::from("Side pills"),
|
||||||
|
"lock" => String::from("Lock screen"),
|
||||||
"bar" => String::from("Bar hiding"),
|
"bar" => String::from("Bar hiding"),
|
||||||
"input-capture" => String::from("Input capture"),
|
"input-capture" => String::from("Input capture"),
|
||||||
"opengl" => String::from("OpenGL"),
|
"opengl" => String::from("OpenGL"),
|
||||||
|
|||||||
+11
-2
@@ -820,14 +820,19 @@ fn compact(items: Vec<Box<dyn Widget>>, width: f32) -> Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// as wide as battery() draws: the bolt, the body and its nub, the percentage
|
// 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 };
|
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)
|
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
|
// 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 {
|
||||||
|
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 theme = theme::now();
|
||||||
|
|
||||||
let low = charge.percent <= 15 && charge.status == "Discharging";
|
let low = charge.percent <= 15 && charge.status == "Discharging";
|
||||||
@@ -849,7 +854,11 @@ fn battery(charge: &Charge) -> Row {
|
|||||||
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
if charge.status == "Charging" {
|
if charge.status == "Charging" {
|
||||||
|
if bolt {
|
||||||
parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent)));
|
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)));
|
parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center)));
|
||||||
|
|||||||
+1
-1
@@ -162,7 +162,7 @@ fn socket(name: &str) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// a question to hyprland, as `hyprctl <request>` would ask it; empty if it can't be asked
|
// a question to hyprland, as `hyprctl <request>` 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 {
|
let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
// glass after Apple's Liquid Glass, as far as a surface that can't see what's behind it
|
||||||
|
// gets: a thin, lightly tinted middle over Hyprland's blur, and an edge drawn like the rim
|
||||||
|
// of a lens: a band that brightens toward the rim where the light falls in (top left) and
|
||||||
|
// where it leaves again (bottom right), a thin specular line on the rim itself, and a faint
|
||||||
|
// warm and cool fringe. surface.rs fills the values:
|
||||||
|
// 0: shape width, height, corner radius, margin (room around the shape for the shadow)
|
||||||
|
// 1: shadow colour, its alpha already scaled by the shadow's strength
|
||||||
|
// 2: shadow deviation, its x and y offset, lift (0 to 1, hovered or raised)
|
||||||
|
// 3: width of the edge band in pixels, light, rim and fringe strengths (0 to 1)
|
||||||
|
// 4-6: the tint, top left to bottom right, plain colours with the middle's opacity as alpha
|
||||||
|
// 7.x: the layer: 1 the shadow and the tint, under the content; 2 the lit edge and the rim,
|
||||||
|
// over it (a photo on the card then lies behind the glass); 0 both
|
||||||
|
|
||||||
|
fn erf7(x: f32) -> f32 {
|
||||||
|
let y = clamp(x * 1.1283791671, -100.0, 100.0);
|
||||||
|
let yy = y * y;
|
||||||
|
let z = y + (0.24295 + (0.03395 + 0.0104 * yy) * yy) * (y * yy);
|
||||||
|
|
||||||
|
return z / sqrt(1.0 + z * z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// vello's blurred rounded rectangle at p, from its centre, as in surface.wgsl
|
||||||
|
fn shadow(p: vec2<f32>, w: f32, h: f32, radius: f32, deviation: f32) -> f32 {
|
||||||
|
let s = max(deviation, 1e-5);
|
||||||
|
let inv = 1.0 / s;
|
||||||
|
let min_edge = min(w, h);
|
||||||
|
let radius_max = 0.5 * min_edge;
|
||||||
|
let r0 = min(length(vec2<f32>(radius, s * 1.15)), radius_max);
|
||||||
|
let r1 = min(length(vec2<f32>(radius, s * 2.0)), radius_max);
|
||||||
|
let exponent = 2.0 * r1 / r0;
|
||||||
|
|
||||||
|
let delta = 1.25 * s * (exp(-pow(0.5 * inv * w, 2.0)) - exp(-pow(0.5 * inv * h, 2.0)));
|
||||||
|
let width = w + min(delta, 0.0);
|
||||||
|
let height = h - max(delta, 0.0);
|
||||||
|
|
||||||
|
let scale = 0.5 * erf7(inv * 0.5 * (max(width, height) - 0.5 * radius));
|
||||||
|
|
||||||
|
let y0 = abs(p.y) - (height * 0.5 - r1);
|
||||||
|
let x0 = abs(p.x) - (width * 0.5 - r1);
|
||||||
|
let d_pos = pow(pow(max(x0, 0.0), exponent) + pow(max(y0, 0.0), exponent), 1.0 / exponent);
|
||||||
|
let d = d_pos + min(max(x0, y0), 0.0) - r1;
|
||||||
|
|
||||||
|
return scale * (erf7(inv * (min_edge + d)) - erf7(inv * d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// signed distance to a rounded rectangle centred on 0, negative inside
|
||||||
|
fn rounded(p: vec2<f32>, half: vec2<f32>, radius: f32) -> f32 {
|
||||||
|
let r = min(radius, min(half.x, half.y));
|
||||||
|
let q = abs(p) - half + vec2<f32>(r);
|
||||||
|
|
||||||
|
return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the way out of the shape at p: across a side, or out from a corner's centre
|
||||||
|
fn outward(p: vec2<f32>, half: vec2<f32>, radius: f32) -> vec2<f32> {
|
||||||
|
let r = min(radius, min(half.x, half.y));
|
||||||
|
let q = abs(p) - half + vec2<f32>(r);
|
||||||
|
let side = select(vec2<f32>(-1.0), vec2<f32>(1.0), p >= vec2<f32>(0.0));
|
||||||
|
|
||||||
|
if q.x > 0.0 && q.y > 0.0 {
|
||||||
|
return normalize(q) * side;
|
||||||
|
}
|
||||||
|
|
||||||
|
return select(vec2<f32>(0.0, side.y), vec2<f32>(side.x, 0.0), q.x > q.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// premultiplied colours, one over the other
|
||||||
|
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
|
||||||
|
return top + under * (1.0 - top.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// amane blends a plain colour over the window
|
||||||
|
fn plain(color: vec4<f32>) -> vec4<f32> {
|
||||||
|
if color.a <= 0.0 {
|
||||||
|
return vec4<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec4<f32>(color.rgb / color.a, color.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let shape = values[0];
|
||||||
|
let extent = shape.xy;
|
||||||
|
let centre = extent / 2.0;
|
||||||
|
let lift = values[2].w;
|
||||||
|
let edge = max(values[3].x, 1.0);
|
||||||
|
let light = values[3].y * (1.0 + 0.35 * lift);
|
||||||
|
let rim = values[3].z * (1.0 + 0.35 * lift);
|
||||||
|
let fringe = values[3].w;
|
||||||
|
|
||||||
|
let layer = u32(values[7].x);
|
||||||
|
|
||||||
|
let p = uv * size - vec2<f32>(shape.w);
|
||||||
|
let pixel = max(fwidth(p.x), 1e-4);
|
||||||
|
let from_centre = p - centre;
|
||||||
|
|
||||||
|
let shade = select(shadow(from_centre - values[2].yz, extent.x, extent.y, shape.z, values[2].x) * values[1].a, 0.0, layer == 2u);
|
||||||
|
let under = vec4<f32>(values[1].rgb * shade, shade);
|
||||||
|
|
||||||
|
let d = rounded(from_centre, centre, shape.z);
|
||||||
|
let inside = clamp(0.5 - d / pixel, 0.0, 1.0);
|
||||||
|
|
||||||
|
if inside <= 0.0 {
|
||||||
|
return plain(under);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tint, a gradient over the shape from its top left to its bottom right
|
||||||
|
let t = clamp(dot(p / max(extent, vec2<f32>(1.0)), vec2<f32>(0.5)), 0.0, 1.0);
|
||||||
|
let tint = select(mix(values[5], values[6], (t - 0.5) * 2.0), mix(values[4], values[5], t * 2.0), t < 0.5);
|
||||||
|
|
||||||
|
var glass = select(vec4<f32>(tint.rgb * tint.a, tint.a), vec4<f32>(0.0), layer == 2u);
|
||||||
|
|
||||||
|
if layer == 1u {
|
||||||
|
return plain(over(glass * inside, under));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the lens: 1 on the rim, 0 deeper in than `edge`, steepest at the rim like a bevel
|
||||||
|
let band = pow(1.0 - smoothstep(0.0, edge, max(-d, 0.0)), 2.0);
|
||||||
|
|
||||||
|
// which way this part of the rim faces: toward the light (top left) is 1, away is -1
|
||||||
|
let facing = dot(outward(from_centre, centre, shape.z), vec2<f32>(-0.7071, -0.7071));
|
||||||
|
|
||||||
|
// a faint warm fringe toward the light and a cool one away from it
|
||||||
|
let hue = mix(vec3<f32>(0.75, 0.85, 1.0), vec3<f32>(1.0, 0.9, 0.78), facing * 0.5 + 0.5);
|
||||||
|
let tone = mix(vec3<f32>(1.0), hue, fringe);
|
||||||
|
|
||||||
|
// brightest where the light comes in, a weaker glint where it goes out, a little all round
|
||||||
|
let caught = clamp(light * band * (0.9 * max(facing, 0.0) + 0.45 * max(-facing, 0.0) + 0.12), 0.0, 1.0);
|
||||||
|
|
||||||
|
glass = over(vec4<f32>(tone * caught, caught), glass);
|
||||||
|
|
||||||
|
// the specular line on the rim itself, about 1.5 px wide
|
||||||
|
let line = clamp(clamp((d + 1.5) / pixel, 0.0, 1.0) * rim * (0.35 + 0.65 * abs(facing)), 0.0, 1.0);
|
||||||
|
|
||||||
|
glass = over(vec4<f32>(tone * line, line), glass);
|
||||||
|
|
||||||
|
return plain(over(glass * inside, under));
|
||||||
|
}
|
||||||
+55
-2
@@ -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 {
|
fn now_unix() -> i64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as 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
|
// a path from the settings, ~/ meaning the home folder
|
||||||
fn expanded(path: &str) -> PathBuf {
|
pub fn expanded(path: &str) -> PathBuf {
|
||||||
match path.strip_prefix("~/") {
|
match path.strip_prefix("~/") {
|
||||||
Some(rest) => home_dir().join(rest),
|
Some(rest) => home_dir().join(rest),
|
||||||
None => PathBuf::from(path),
|
None => PathBuf::from(path),
|
||||||
@@ -1049,6 +1060,48 @@ fn month_name(month: u32) -> &'static str {
|
|||||||
MONTHS[(month as usize + 11) % 12]
|
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<Color>)> {
|
||||||
|
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 {
|
fn sky_glyph(code: u32, day: bool) -> Glyph {
|
||||||
match parse::sky(code) {
|
match parse::sky(code) {
|
||||||
Sky::Clear if day => Glyph::Sun,
|
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()
|
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 user = env::var("USER").unwrap_or_default();
|
||||||
let mut letters = user.chars();
|
let mut letters = user.chars();
|
||||||
|
|
||||||
|
|||||||
+488
@@ -0,0 +1,488 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
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, glyph_shapes};
|
||||||
|
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)
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
#[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";
|
||||||
|
|
||||||
|
static SHADER: LazyLock<PathBuf> = 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<Box<dyn Shape>>;
|
||||||
|
|
||||||
|
// a chip's parts, how wide they are, and the icon the canvas draws at their start
|
||||||
|
type Chip = (Vec<Box<dyn Widget>>, 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() {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 mut icons: Icons = Vec::new();
|
||||||
|
|
||||||
|
// 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 lower_y = height - look::bottom() - lower_height();
|
||||||
|
let lower = lower(&clock, width, lower_y, &mut icons);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let content = Column::new(children![top, bottom]).width(width).height(height).justify(SpaceBetween).align(Center);
|
||||||
|
|
||||||
|
let mut layers = backdrop(width, height, shown.picture.as_deref());
|
||||||
|
|
||||||
|
layers.push(Box::new(content));
|
||||||
|
|
||||||
|
if !icons.is_empty() {
|
||||||
|
layers.push(Box::new(Canvas::new().width(width).height(height).shapes(icons)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stack::new(layers).width(width).height(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Box<dyn Widget>> {
|
||||||
|
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<dyn Widget>
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
let mut row: Vec<Box<dyn Widget>> = vec![Box::new(words(&date, size, theme.text, true, date_w))];
|
||||||
|
let mut row_w = date_w;
|
||||||
|
|
||||||
|
if show::weather()
|
||||||
|
&& let Some((glyph, said)) = home::weather_now()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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 clock_h = digits.height_in(width);
|
||||||
|
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = 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 said = format!("“{}”", shown.splash);
|
||||||
|
let said_w = (words_width(&said, size) + size).min(width * 0.7);
|
||||||
|
|
||||||
|
parts.push(Box::new(words(&said, size, theme.dim, false, said_w)));
|
||||||
|
height += inner + size * 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
(Rectangle::new().width(width).height(height).align_child(Center, Start).child(Column::new(parts).gap(inner).align(Center)), height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Box<dyn Widget>> = Vec::new();
|
||||||
|
let mut y = y;
|
||||||
|
|
||||||
|
if show::music()
|
||||||
|
&& let Some(card) = music(width, y, icons)
|
||||||
|
{
|
||||||
|
parts.push(Box::new(card));
|
||||||
|
y += look::card_height() + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(chips) = chips(now, width, y, icons) {
|
||||||
|
parts.push(Box::new(chips));
|
||||||
|
}
|
||||||
|
|
||||||
|
Column::new(parts).gap(gap).align(Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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 (card_w, card_h) = (look::card_width(), look::card_height());
|
||||||
|
let gap = look::gap() * 0.75;
|
||||||
|
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() {
|
||||||
|
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 (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() };
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
let mut items: Vec<Box<dyn Widget>> = vec![Box::new(picture), Box::new(said)];
|
||||||
|
|
||||||
|
if show::controls() {
|
||||||
|
items.push(Box::new(controls(player, (left + cover + gap + words_w + gap, middle), small, big, icons)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(card_w, card_h, layout::radius().min(card_h / 2.0), &theme.surfaces(), 0.0, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 middle = if player.playing() { Glyph::Pause } else { Glyph::Play };
|
||||||
|
|
||||||
|
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<dyn Fn()>| 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a glass chip around `parts`, which are `inner` wide
|
||||||
|
fn chip(parts: Vec<Box<dyn Widget>>, 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));
|
||||||
|
|
||||||
|
surface::shell(width, height, height / 2.0, &theme::now().surfaces(), 0.0, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Row> {
|
||||||
|
let theme = theme::now();
|
||||||
|
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<Chip> = 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));
|
||||||
|
|
||||||
|
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 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());
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wanted.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: f32 = wanted.iter().map(|(_, inner, _)| inner + height).sum::<f32>() + spacing * (wanted.len() - 1) as f32;
|
||||||
|
let mut x = (width - total) / 2.0;
|
||||||
|
|
||||||
|
let mut row: Vec<Box<dyn Widget>> = 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
let avatar = match clock.avatar() {
|
||||||
|
Some(path) => {
|
||||||
|
let pixels = (size * 2.0).ceil() as u32;
|
||||||
|
|
||||||
|
avatar.fill(Image::cover(path).thumbnail(pixels, pixels))
|
||||||
|
}
|
||||||
|
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 = 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 (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 field_y = y + size + gap + name_height() + gap;
|
||||||
|
|
||||||
|
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(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)
|
||||||
|
} else if lock.failed() {
|
||||||
|
("Wrong password, try again", look::wrong_color())
|
||||||
|
} else {
|
||||||
|
("", theme.dim)
|
||||||
|
};
|
||||||
|
|
||||||
|
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(gap).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<f32>) -> @location(0) vec4<f32> {
|
||||||
|
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<f32>(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<f32>(gradient.rgb, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if layer == 2u {
|
||||||
|
return vec4<f32>(0.0, 0.0, 0.0, dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec4<f32>(gradient.rgb * (1.0 - dark), 1.0);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod home;
|
|||||||
mod hypr;
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
mod last;
|
mod last;
|
||||||
|
mod lock;
|
||||||
mod map;
|
mod map;
|
||||||
mod notices;
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
@@ -44,6 +45,8 @@ fn main() {
|
|||||||
.ipc("tray", tray::command)
|
.ipc("tray", tray::command)
|
||||||
.ipc("config", settings::command)
|
.ipc("config", settings::command)
|
||||||
.ipc("pods", pods::command)
|
.ipc("pods", pods::command)
|
||||||
|
.ipc("lock", lock::command)
|
||||||
|
.lock(lock::view)
|
||||||
.window_per_monitor(edge::spacer)
|
.window_per_monitor(edge::spacer)
|
||||||
.window_per_monitor(edge::view)
|
.window_per_monitor(edge::view)
|
||||||
.window_per_monitor(island::view)
|
.window_per_monitor(island::view)
|
||||||
|
|||||||
+48
-1
@@ -3,7 +3,7 @@ use std::sync::LazyLock;
|
|||||||
|
|
||||||
use amane::{Blend, Color, Rectangle, Stack, children};
|
use amane::{Blend, Color, Rectangle, Stack, children};
|
||||||
|
|
||||||
use crate::config::appearance::{frosted, liquid, solid, surface};
|
use crate::config::appearance::{frosted, glass, liquid, solid, surface};
|
||||||
use crate::island::faded;
|
use crate::island::faded;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ use crate::theme;
|
|||||||
* the rim stays a border, which amane draws as a quad
|
* the rim stays a border, which amane draws as a quad
|
||||||
*/
|
*/
|
||||||
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||||
|
if surface::design() == "glass" {
|
||||||
|
return glass_shell(width, height, radius, colors, lift, content);
|
||||||
|
}
|
||||||
|
|
||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
|
||||||
let look = match surface::design().as_str() {
|
let look = match surface::design().as_str() {
|
||||||
@@ -89,6 +93,49 @@ pub fn clear() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
|
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
|
||||||
|
static GLASS: LazyLock<PathBuf> = LazyLock::new(|| shader_file("glass.wgsl", include_str!("glass.wgsl")));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* after Apple's Liquid Glass: glass.wgsl draws the shadow and the thin tint in the
|
||||||
|
* wallpaper's colours under the content, and the lens-like edge with its rim over it, so a
|
||||||
|
* photo on a card lies behind the glass; there's no border. the frosting behind is
|
||||||
|
* Hyprland's blur; the desktop isn't bent at the rim the way Apple's glass bends it, since
|
||||||
|
* a shell's shader can't see what's behind its window
|
||||||
|
*/
|
||||||
|
fn glass_shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||||
|
let (blur, drop): (f32, f32) = (22.0, 6.0);
|
||||||
|
let margin = (1.25 * blur + drop).ceil();
|
||||||
|
|
||||||
|
let mut shadow = plain(Color::BLACK);
|
||||||
|
|
||||||
|
shadow[3] = (glass::shadow() + 0.1 * lift).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let tint = |color: Color| {
|
||||||
|
let mut tint = plain(color);
|
||||||
|
|
||||||
|
tint[3] = glass::opacity().clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
tint
|
||||||
|
};
|
||||||
|
|
||||||
|
// the under layer covers the shadow's room around the shape too, the edge only the shape
|
||||||
|
let layer = |which: f32, margin: f32| {
|
||||||
|
let values = vec![
|
||||||
|
[width, height, radius, margin],
|
||||||
|
shadow,
|
||||||
|
[blur / 2.0, 0.0, drop, lift],
|
||||||
|
[glass::edge(), glass::light(), glass::rim(), glass::dispersion()],
|
||||||
|
tint(colors[0]),
|
||||||
|
tint(colors[1]),
|
||||||
|
tint(colors[2]),
|
||||||
|
[which, 0.0, 0.0, 0.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
Rectangle::new().width(width + 2.0 * margin).height(height + 2.0 * margin).translate(-margin, -margin).shader(GLASS.as_path()).shader_values(values)
|
||||||
|
};
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Stack::new(children![layer(1.0, margin), content, layer(2.0, 0.0)]).width(width).height(height))
|
||||||
|
}
|
||||||
|
|
||||||
// a shader's source is part of the shell; amane wants a file, so it goes beside cava's config
|
// a shader's source is part of the shell; amane wants a file, so it goes beside cava's config
|
||||||
pub fn shader_file(name: &str, source: &str) -> PathBuf {
|
pub fn shader_file(name: &str, source: &str) -> PathBuf {
|
||||||
|
|||||||
Reference in New Issue
Block a user