Compare commits

2 Commits
Author SHA1 Message Date
server2 6399844731 New workspaces grow in; config saves don't redraw everything or race; media peek skips the player in front
- A workspace Hyprland makes for a switch (createworkspacev2) grows into the row
  over the slide instead of appearing at full size; the window keeps the full
  room meanwhile.
- Every config.toml save restarted the colour Animation, and Animation::new
  runs its clock even from a colour to itself, so every window redrew for 0.4 s
  per save. The colours now only fade when they change.
- The file watch could read config.toml halfway through the shell's own save
  and reset settings to their defaults. Saves and rereads share one lock, the
  file is written to a new file and renamed over, and the shell's own saves
  are skipped by the watch.
- A song change or new player only pops the island when the focused window
  isn't that player's app (parse::same_app, tested).
2026-10-15 11:28:44 +02:00
server2 5d322b940d Glass designs as settings: solid, frosted, liquid on every surface
appearance.surface.design picks the look (chips in the config window); each
design's opacity, rim and light are settings. surface::shell now draws the
island, idle pill, workspace and tray pills, tray menu, control centre and
popup, so a design applies everywhere. On see-through glass faces fade with
.opacity(). Shell text settings can list their allowed words (chips).
2026-10-15 03:05:06 +02:00
13 changed files with 393 additions and 153 deletions
+18
View File
@@ -117,6 +117,23 @@ Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
### 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`. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
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).
```lua
hl.layer_rule({
name = "amane-glass",
match = { namespace = "^magic-.*$" },
blur = true,
ignore_alpha = 0.2,
})
```
The blur's strength is Hyprland's own (`decoration.blur.size`, `passes`; also on the config window's Decoration page).
### Hyprland options
The Hyprland pages list every option Hyprland itself describes (`hyprctl descriptions`), with its current value and default, and whether your Hyprland config sets it. A change applies at once (`hl.config`) and is kept in `config.toml` under `[hyprland.*]`; the shell writes those to `~/.config/hypr/amane.lua`. Load that file last in `hyprland.lua`, so the window's values survive a Hyprland reload and win over the rest of your config:
@@ -147,6 +164,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/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs
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
+81 -6
View File
@@ -32,6 +32,8 @@ pub struct Opt {
pub help: &'static str,
pub kind: Kind,
pub range: Option<(f64, f64)>,
// the only words a text setting takes, shown as chips; empty: any text
pub choices: &'static [&'static str],
pub default: fn() -> Value,
}
@@ -170,7 +172,7 @@ pub fn color(text: &str) -> Option<Color> {
}
macro_rules! settings {
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty = $default:expr $(, $min:literal ..= $max:literal)? ; $help:literal )* } )* } )*) => {
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty $([ $($choice:literal),+ ])? = $default:expr $(, $min:literal ..= $max:literal)? ; $help:literal )* } )* } )*) => {
$( pub mod $module {
$( pub mod $group {
#[allow(unused_imports)]
@@ -190,6 +192,7 @@ macro_rules! settings {
help: $help,
kind: <$ty as Setting>::KIND,
range: settings!(@range $($min, $max)?),
choices: &[ $( $($choice),+ )? ],
default: || <$ty as Setting>::value(&$default),
}, )* )* )* ];
};
@@ -221,6 +224,27 @@ settings! {
motion {
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
}
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"
}
solid {
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
glow: f32 = 0.3, 0.0..=1.0; "Strength of the accent glow around each surface"
hover_glow: f32 = 0.7, 0.0..=1.0; "Strength of the glow while hovered"
}
frosted {
opacity: f32 = 0.62, 0.05..=1.0; "How opaque the frosted tint is"
rim: f32 = 0.19, 0.0..=1.0; "Opacity of the hairline white rim"
sheen: f32 = 0.13, 0.0..=1.0; "Opacity of the white highlight along the top edge"
shadow: f32 = 0.24, 0.0..=1.0; "Strength of the soft shadow under each surface"
}
liquid {
opacity: f32 = 0.4, 0.05..=1.0; "How opaque the glass is in its middle; clearer at the top left, denser at the bottom right"
rim: f32 = 0.33, 0.0..=1.0; "Opacity of the light rim"
rim_width: f32 = 1.5, 0.0..=4.0; "Width of the light rim, in pixels"
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"
}
fallback {
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"
@@ -481,6 +505,17 @@ static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default:
// what was wrong in the file when it was last read, for the config window
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/*
* config.toml as the shell last wrote or read it. every save and every reread holds
* this lock, so the file watch can't read a save halfway or undo a change it raced
* with (it once reset settings to their defaults that way), and skips the shell's own saves
*/
static WRITTEN: Mutex<String> = Mutex::new(String::new());
fn written() -> std::sync::MutexGuard<'static, String> {
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
}
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
@@ -496,17 +531,37 @@ pub fn option(path: &str) -> Option<&'static Opt> {
// at startup: config.toml, or the first time, one made from the old shell.conf
pub fn load() {
let mut written = written();
let text = fs::read_to_string(file()).unwrap_or_else(|_| {
migrate();
let text = render();
let _ = fs::write(file(), &text);
let _ = write_file(&text);
text
});
apply_text(&text);
*written = text;
}
// config.toml changed on disk: a hand edit applies, the shell's own saves are skipped. whether it applied
pub fn reread() -> bool {
let mut written = written();
let text = fs::read_to_string(file()).unwrap_or_default();
if text == *written {
return false;
}
apply_text(&text);
*written = text;
true
}
// shell.conf's four settings, moved to their places in config.toml
@@ -575,6 +630,10 @@ fn check(path: &str, value: &Value) -> Result<(), String> {
return Err(format!("needs {}", kind_words(option)));
}
if !option.choices.is_empty() && !value.text().is_some_and(|word| option.choices.contains(&word)) {
return Err(format!("must be one of {}", option.choices.join(", ")));
}
let numbers: Vec<f64> = match value {
Value::List(items) => items.iter().filter_map(Value::number).collect(),
_ => value.number().into_iter().collect(),
@@ -639,26 +698,42 @@ pub fn override_of(path: &str) -> Option<Value> {
pub fn set(path: &str, value: Value) -> Result<(), String> {
check(path, &value)?;
let mut written = written();
VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value);
save()
save(&mut written)
}
// back to the default (a Hyprland option: back to Hyprland's own config)
pub fn reset(path: &str) -> Result<(), String> {
let mut written = written();
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path);
save()
save(&mut written)
}
fn save() -> Result<(), String> {
fs::write(file(), render()).map_err(|error| format!("couldn't write {}: {error}", file().display()))?;
fn save(written: &mut String) -> Result<(), String> {
let text = render();
write_file(&text).map_err(|error| format!("couldn't write {}: {error}", file().display()))?;
*written = text;
theme::touch();
Ok(())
}
// whole or not at all: a new file renamed over the old, so no reader sees half of it
fn write_file(text: &str) -> std::io::Result<()> {
let fresh = file().with_extension("toml.new");
fs::write(&fresh, text)?;
fs::rename(&fresh, file())
}
// config.toml as the shell writes it: every setting with its help, then the Hyprland options set here
pub fn render() -> String {
let mut out = String::from(
+8 -13
View File
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use amane::{
Animation, Audio, Blend, Bluetooth, Brightness, Button, Canvas, Cap, Center, Color, Column, Easing, End, Full,
Horizontal, InputArea, Layer, LayerWindow, Line, Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow,
Horizontal, InputArea, Layer, LayerWindow, Line, Monitor, Padding, Parent, Pointer, Rectangle, Row, Service,
Shape, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
};
@@ -20,14 +20,14 @@ use crate::config::control as c;
use crate::config::island::{animation as island_animation, pill as island_pill};
use crate::edge::{self, Edge};
use crate::glyph::{Glyph, icon};
use crate::island::{self, CLEAR, backdrop, faded, mix, phases, veiled};
use crate::island::{self, CLEAR, faded, mix, phases, veiled};
use crate::map::{self, Centre, Link, Node};
use crate::parse;
use crate::prompt;
use crate::settings;
use crate::sound::Sound;
use crate::system::{self, Charge, Gpu, Links, Power, VpnState};
use crate::theme;
use crate::{surface, theme};
// every size and timing is a setting (config.rs: control.*); these follow from them
fn menu_w() -> f32 {
@@ -452,14 +452,10 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let tapped = monitor.name.clone();
let hovered = monitor.name.clone();
let shape = Rectangle::new()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme::now().outline, faded(theme::now().accent, 110), lift))
.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0))
.align_child(Center, Start)
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
// the surface is surface.rs's, like the island's
let shape = surface::shell(width, height, radius, &colors, lift, face)
.on_click(move |_| tap(&tapped))
.on_hover(move |inside| {
{
@@ -481,8 +477,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
if !inside && panel_here {
collapse_soon();
}
})
.child(face);
});
let shape = if control.expanded && opened_here { shape } else { shape.cursor(Pointer) };
+33 -2
View File
@@ -56,6 +56,9 @@ pub struct Edge {
pops: HashMap<(Part, String), Instant>,
switched: HashMap<String, (i64, Instant)>,
// workspaces hyprland made a moment ago (by switching to a new one), by id
created: HashMap<i64, Instant>,
// the monitor whose top edge the pointer rests on, and whether it's over a part of the bar
at_edge: Option<String>,
over_bar: bool,
@@ -84,6 +87,16 @@ impl Service for Edge {
let event = line.split(">>").next().unwrap_or("");
// createworkspacev2>>ID,NAME
if event == "createworkspacev2"
&& let Some(id) = line.split(">>").nth(1).and_then(|data| data.split(',').next()).and_then(|id| id.parse::<i64>().ok())
{
let mut edge = Edge::write();
edge.created.retain(|_, at| at.elapsed().as_secs_f32() < peeks::switch_s());
edge.created.insert(id, Instant::now());
}
if matches!(
event.trim_end_matches("v2"),
"fullscreen" | "workspace" | "focusedmon" | "openwindow" | "closewindow" | "movewindow" | "moveworkspace" | "activespecial" | "monitoradded" | "monitorremoved"
@@ -130,6 +143,11 @@ impl Edge {
(t < 1.0).then_some((*from, t))
}
// how far a workspace hyprland just made has grown into the row, 0 to 1 over the switch; 1 for the rest
pub fn grown(&self, id: i64) -> f32 {
self.created.get(&id).map_or(1.0, |at| (at.elapsed().as_secs_f32() / peeks::switch_s().max(0.01)).min(1.0))
}
fn fullscreen_on(&self, monitor: &str) -> bool {
self.fullscreen.contains(monitor)
}
@@ -292,10 +310,15 @@ fn watch_media() {
};
if let Some((known, before)) = &last {
let new_player = players.iter().any(|name| !known.contains(name));
let new_player = players.iter().find(|name| !known.contains(name));
let new_song = !before.is_empty() && song != *before;
if new_player || new_song {
// the one that changed: a new player, or the one whose song this is
let changed = new_player.cloned().or_else(|| new_song.then(|| song.split('\u{1f}').next().unwrap_or("").to_string()));
if let Some(player) = changed
&& !in_front(&player)
{
announce_media();
}
}
@@ -304,6 +327,14 @@ fn watch_media() {
}
}
// whether the focused window is this player's app: then its song change is in plain sight
fn in_front(player: &str) -> bool {
let window = parse::json(&hyprctl("j/activewindow"));
let class = window.first().and_then(|window| window.get("class")).and_then(parse::Value::text).unwrap_or("");
parse::same_app(class, player)
}
fn announce_media() {
let mut edge = Edge::write();
+20 -23
View File
@@ -16,7 +16,7 @@ use crate::glyph::{Glyph, bold_icon, icon};
use crate::parse::{self, Sink};
use crate::sound::{self, Sound};
use crate::spectrum::Spectrum;
use crate::theme;
use crate::{surface, theme};
/*
* opening is one linear timeline in two phases, so the shape never resizes with the
@@ -317,26 +317,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new().width(0.0).height(0.0)
};
let shape = Rectangle::new()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme::now().outline, faded(theme::now().accent, 110), lift));
// the face centred along the top: while the shape grows it is narrower than the shape
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
/*
* one faint pink glow around the island, pill and card alike, stronger while the
* pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation
* of blur / 2), inside the window's c::pill::shadow() margin. no clip anywhere: neither face
* ever overflows the shape
* the surface (fill, rim, glow; stronger while hovered) is surface.rs's. its glow fades
* out inside the window's c::pill::shadow() margin. no clip anywhere: neither face ever
* overflows the shape
*/
let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0));
let shape = surface::shell(width, height, radius, &colors, lift, face);
let tapped = monitor.name.clone();
let hovered = monitor.name.clone();
let shape = shape
.align_child(Center, Start)
.on_click(move |_| tap(&tapped))
.on_hover(move |inside| {
if inside {
@@ -352,8 +346,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
if !inside && card_here {
collapse();
}
})
.child(face);
});
let shape = if island.expanded && opened_here { shape } else { shape.cursor(Pointer) };
@@ -539,13 +532,19 @@ pub fn phases(timeline: f32) -> (f32, f32, f32) {
* fades a face in over the island's backdrop by laying the same gradient over it,
* thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer.
* the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with
* the backdrop's exactly; amane places gradients by each rectangle's own bounds
* the backdrop's exactly; amane places gradients by each rectangle's own bounds.
* see-through glass can't hide anything under a veil, so there faces fade with
* .opacity(), a gpu layer only while the fade runs
*/
pub fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
if shown >= 1.0 {
return face;
}
if surface::clear() {
return Rectangle::new().width(width).height(height).align_child(Center, Start).child(face).opacity(shown);
}
let veil = Rectangle::new()
.width(width - 2.0)
.height(height - 2.0)
@@ -590,17 +589,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
// the island while no player is open: a music note and "Nothing playing", glowing like the pill
// the island while no player is open: a music note and "Nothing playing", on the pill's surface
fn idle(colors: &[Color; 3]) -> Rectangle {
Rectangle::new()
let content = Rectangle::new()
.width(c::pill::width())
.height(c::pill::height())
.radius(Full)
.fill(backdrop(colors, 255))
.border(1.0, theme::now().outline)
.shadow(Shadow::drop(theme::now().accent).opacity(0.3).blur(22.0))
.align_child(Center, Center)
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center))
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center));
surface::shell(c::pill::width(), c::pill::height(), c::pill::height() / 2.0, colors, 0.0, content)
}
// the pill: cover, title, the player's live levels
+1
View File
@@ -11,6 +11,7 @@ mod prompt;
mod settings;
mod sound;
mod spectrum;
mod surface;
mod system;
mod png;
mod theme;
+23
View File
@@ -626,6 +626,18 @@ pub fn hypr_monitors(printed: &str) -> Vec<(String, i64, bool)> {
monitors
}
/*
* whether a window's class ("Cider", "org.mozilla.firefox") is the app behind an MPRIS
* player's bus name (org.mpris.MediaPlayer2.cider, ...firefox.instance_1_42): the name's
* first part and the class, either inside the other, ignoring case
*/
pub fn same_app(class: &str, player: &str) -> bool {
let class = class.trim().to_lowercase();
let app = player.strip_prefix("org.mpris.MediaPlayer2.").unwrap_or(player).split('.').next().unwrap_or("").to_lowercase();
!class.is_empty() && !app.is_empty() && (class.contains(&app) || app.contains(&class))
}
/*
* `hyprctl clients` (plain text): the workspaces showing a fullscreen window. fullscreen
* 2 is fullscreen and 3 fullscreen and maximised; 1 is only maximised, which keeps the bar
@@ -1473,6 +1485,17 @@ Sink #80
}
}
#[test]
fn matches_windows_to_players() {
assert!(same_app("Cider", "org.mpris.MediaPlayer2.cider"));
assert!(same_app("firefox", "org.mpris.MediaPlayer2.firefox.instance_1_42"));
assert!(same_app("org.mozilla.firefox", "org.mpris.MediaPlayer2.firefox.instance_1_42"));
assert!(same_app("chromium-browser", "org.mpris.MediaPlayer2.chromium.instance1234"));
assert!(!same_app("com.mitchellh.ghostty", "org.mpris.MediaPlayer2.cider"));
assert!(!same_app("", "org.mpris.MediaPlayer2.cider"));
assert!(!same_app("firefox", ""));
}
#[test]
fn reads_hyprland_json_and_writes_lua() {
let printed = "{\"option\": \"general:border_size\", \"int\": 2, \"set\": true }\n\ninvalid type (internal error) é\n{\"option\": \"decoration:rounding_power\", \"float\": 3.000000, \"set\": true }\n\n\
+5 -12
View File
@@ -14,7 +14,7 @@ use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin,
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, Start, Text, TextInput, Vertical, Weight, Widget,
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Start, Text, TextInput, Vertical, Weight, Widget,
Zone, children,
};
@@ -24,10 +24,10 @@ use crate::config::island::pill as island_pill;
use crate::config::popups::{layout, timing};
use crate::control::{self, Control};
use crate::glyph::{Glyph, icon};
use crate::island::{self, backdrop, veiled};
use crate::island::{self, veiled};
use crate::parse::{self, Pairing};
use crate::system;
use crate::theme;
use crate::{surface, theme};
// the popup's sizes are settings (config.rs: popups.*)
fn inner_w() -> f32 {
@@ -621,15 +621,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let content = Rectangle::new().width(layout::width()).height(card_h).padding(layout::pad()).child(Column::new(rows).gap(layout::gap()));
let card = Rectangle::new()
.width(layout::width())
.height(card_h)
.radius(22.0)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme.outline, theme.accent, 0.5))
.shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0))
.translate(0.0, 8.0 * (1.0 - appear))
.child(veiled(content, layout::width(), card_h, 22.0, appear, &colors));
// raised a little over the control centre: a stronger glow and rim than a resting surface
let card = surface::shell(layout::width(), card_h, 22.0, &colors, 0.375, veiled(content, layout::width(), card_h, 22.0, appear, &colors)).translate(0.0, 8.0 * (1.0 - appear));
// over the middle of the map menu, a little below the control centre's top
let (right, top) = (control_pill::right() + (control::map_width(monitor.width as f32) - layout::width()) / 2.0 - island_pill::shadow(), island_pill::top_gap() + 110.0 - island_pill::shadow());
+29 -6
View File
@@ -480,6 +480,14 @@ fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
}
(Kind::Text, Value::Text(current)) if !option.choices.is_empty() => Box::new(chips(
option
.choices
.iter()
.map(|&word| (String::from(word), current == word, Box::new(move || say(config::set(option.path, Value::Text(String::from(word))), format!("{} = {word}", option.path))) as Box<dyn Fn()>))
.collect(),
colors,
)),
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == Kind::Int;
@@ -514,8 +522,6 @@ fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
}
let _ = colors;
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
}
@@ -633,14 +639,31 @@ fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
}
// a Hyprland number with named values: one chip per name
// a Hyprland option's named values as chips
fn choices(option: &HyprOption, colors: &Colors) -> Row {
let name = option.name;
let chips: Vec<Box<dyn Widget>> = option
chips(
option
.choices
.iter()
.map(|(label, value)| {
let chosen = option.current == value.to_string();
let value = value.to_string();
(label.replace('_', " "), option.current == value, Box::new(move || hypr::set(name, &value)) as Box<dyn Fn()>)
})
.collect(),
colors,
)
}
// one choice: its label, whether it's the current one, what a click does
type Choice = (String, bool, Box<dyn Fn()>);
fn chips(items: Vec<Choice>, colors: &Colors) -> Row {
let chips: Vec<Box<dyn Widget>> = items
.into_iter()
.map(|(label, chosen, pick)| {
let letters = label.chars().count() as f32;
Box::new(
@@ -651,8 +674,8 @@ fn choices(option: &HyprOption, colors: &Colors) -> Row {
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| hypr::set(name, &value))
.child(Text::new(label.replace('_', " ")).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
.on_click(move |_| pick())
.child(Text::new(label).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
) as Box<dyn Widget>
})
.collect();
+74
View File
@@ -0,0 +1,74 @@
use amane::{Blend, Color, Gradient, Rectangle, Shadow, Stack, children};
use crate::config::appearance::{frosted, liquid, solid, surface};
use crate::island::{backdrop, faded};
use crate::theme;
/*
* what the island, its idle pill, the workspace and tray pills, the tray menu, the control
* centre and its popup are made of: one place, so a design is this file. `shell` wraps
* `content` (already width x height, with its own padding and alignment) in the surface.
* `lift` (0 to 1) is how raised it is: hovered, or a popup over the rest.
* see-through designs need Hyprland's blur behind the shell (README: Glass)
*/
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
let theme = theme::now();
let base = Rectangle::new().width(width).height(height).radius(radius);
match surface::design().as_str() {
// after Apple's materials: a thin tint, a hairline light rim (the accent when hovered),
// a soft highlight along the top as if lit from above, one soft low shadow instead of a glow
"frosted" => {
let sheen = inner(width, height, radius).fill(Gradient::linear(180.0, [(0.0, white(frosted::sheen())), (0.3, white(frosted::sheen() * 0.26)), (0.65, white(0.0))]));
base.fill(backdrop(colors, alpha(frosted::opacity())))
.border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift))
.shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(0.0, 6.0))
.child(Stack::new(children![sheen, content]).width(width).height(height))
}
// clear glass, denser toward the bottom right where the equalizer's labels sit; a bright
// edge where light enters at the top left and leaves at the bottom right; the accent's glow
"liquid" => {
let glass = liquid::opacity();
let fill = Gradient::linear(150.0, [(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]);
let light = liquid::light();
let edge = inner(width, height, radius).fill(Gradient::linear(
135.0,
[(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))],
));
base.fill(fill)
.border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift))
.shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.0))
.child(Stack::new(children![edge, content]).width(width).height(height))
}
// the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent
_ => base
.fill(backdrop(colors, alpha(solid::opacity())))
.border(1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift))
.shadow(Shadow::drop(theme.accent).opacity(solid::glow() + (solid::hover_glow() - solid::glow()) * lift).blur(22.0))
.child(content),
}
}
// whether the desktop shows through: then a veil can't hide a face, so faces fade with .opacity()
pub fn clear() -> bool {
surface::design() != "solid" || solid::opacity() < 1.0
}
// a layer just inside the rim, so the rim stays crisp over it
fn inner(width: f32, height: f32, radius: f32) -> Rectangle {
Rectangle::new().width(width - 2.0).height(height - 2.0).radius((radius - 1.0).max(0.0)).translate(1.0, 1.0)
}
// an opacity, 0 to 1, as a colour's alpha
fn alpha(opacity: f32) -> u8 {
(opacity.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn white(opacity: f32) -> Color {
Color::rgba(255, 255, 255, alpha(opacity))
}
+39 -31
View File
@@ -76,24 +76,41 @@ impl Blend for Colors {
pub struct Theme {
colors: Animation<Colors>,
// where the colours are going; a reread that lands on them again starts no fade
target: Colors,
}
impl Service for Theme {
fn new() -> Self {
Self { colors: Animation::new(fallback()) }
Self { colors: Animation::new(fallback()), target: fallback() }
}
/*
* waits on the kernel instead of polling: amane::watch_file watches the folder, so
* matugen writing a new file and renaming it over the old one is seen too.
* config.toml on its own thread, the theme here; either change re-reads both
* config.toml on its own thread, the theme here. a changed config.toml redraws
* everything; the colours fade only if it changed them (its fallback colours)
*/
fn listen() {
reload();
reload(true);
thread::spawn(|| follow("config.toml"));
thread::spawn(|| {
for _ in amane::watch_file(&config_file("config.toml").to_string_lossy()) {
// one save can arrive as several writes: let it finish, then read once
thread::sleep(Duration::from_millis(50));
follow("theme");
if config::reread() {
touch();
reload(true);
}
}
});
for _ in amane::watch_file(&config_file("theme").to_string_lossy()) {
thread::sleep(Duration::from_millis(50));
reload(false);
}
}
}
@@ -102,41 +119,26 @@ pub fn touch() {
let _ = Theme::write();
}
// what was read last, (theme, config.toml); none until the first read
static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);
// the theme file as read last; none until the first read
static LAST: Mutex<Option<String>> = Mutex::new(None);
fn follow(name: &str) {
for _ in amane::watch_file(&config_file(name).to_string_lossy()) {
// one save can arrive as several writes: let it finish, then read once
thread::sleep(Duration::from_millis(50));
reload();
}
}
// a deleted theme file sends no event: the defaults come back at the next start
fn reload() {
let read = (read("theme"), read("config.toml"));
/*
* the theme file, read anew; `again` also when it's unchanged, for new fallback colours.
* a deleted theme file sends no event: the defaults come back at the next start
*/
fn reload(again: bool) {
let theme = fs::read_to_string(config_file("theme")).unwrap_or_default();
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
// a write that changes nothing redraws nothing
if last.as_ref() == Some(&read) {
if last.as_ref() == Some(&theme) && !again {
return;
}
if last.as_ref().is_none_or(|(_, config)| *config != read.1) {
config::apply_text(&read.1);
}
// the colours at startup show at once, later changes cross-fade
apply(&read.0, last.is_none());
apply(&theme, last.is_none());
*last = Some(read);
}
fn read(name: &str) -> String {
fs::read_to_string(config_file(name)).unwrap_or_default()
*last = Some(theme);
}
// $XDG_CONFIG_HOME, or ~/.config
@@ -178,10 +180,16 @@ fn apply(theme: &str, startup: bool) {
}
}
if !startup && Theme::read().target == colors {
return;
}
let fade = if startup { 0 } else { config::appearance::colors::transition_ms() };
let mut theme = Theme::write();
theme.target = colors;
// a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value();
+9 -23
View File
@@ -16,7 +16,7 @@ use std::{env, fs, thread};
use amane::{
Animation, Argument, Blend, Bus, Button, Center, Color, Column, Full, Horizontal, Image, InputArea, Layer,
LayerWindow, Margin, Monitor, Padding, Parent, Pointer, Rectangle, Row, Scroll, Service, Shadow, SpaceBetween,
LayerWindow, Margin, Monitor, Padding, Parent, Pointer, Rectangle, Row, Scroll, Service, SpaceBetween,
Start, Text, Value, Vertical, Weight, Widget, Zone,
};
@@ -24,8 +24,8 @@ use crate::config::appearance::{motion, overlays, text};
use crate::config::island::pill as island_pill;
use crate::config::tray::{menu, pill};
use crate::glyph::{Glyph, icon};
use crate::island::{CLEAR, backdrop, faded, veiled};
use crate::{edge, parse, png, theme};
use crate::island::{CLEAR, faded, veiled};
use crate::{edge, parse, png, surface, theme};
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
@@ -702,17 +702,9 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
slots.push(Box::new(slot));
}
let pill = Rectangle::new()
.width(width)
.height(island_pill::height())
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(pill::inset())
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Row::new(slots).gap(pill::slot_gap()).align(Center));
let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Row::new(slots).gap(pill::slot_gap()).align(Center));
let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar);
Some((pill, width))
}
@@ -822,13 +814,8 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let content = Rectangle::new().width(menu::width()).height(height).padding(menu::pad()).child(Column::new(rows));
let card = Rectangle::new()
.width(menu::width())
.height(height)
.radius(16.0)
.fill(backdrop(&colors, 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.35).blur(22.0))
// raised a little over the bar
let card = surface::shell(menu::width(), height, 16.0, &colors, 0.125, veiled(content, menu::width(), height, 16.0, appear, &colors))
.translate(0.0, -6.0 * (1.0 - appear))
.on_hover(|inside| {
if inside {
@@ -839,8 +826,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
} else {
leave_soon();
}
})
.child(veiled(content, menu::width(), height, 16.0, appear, &colors));
});
let left = (open.x - 8.0 - island_pill::shadow()).max(0.0);
let top = island_pill::top_gap() + island_pill::height() + 6.0 - island_pill::shadow();
+49 -33
View File
@@ -5,15 +5,15 @@ use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
Rectangle, Row, Service, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
};
use crate::config::appearance::{motion, overlays, text};
use crate::config::island::pill as island_pill;
use crate::config::workspaces::pill;
use crate::edge::{self, Edge, Part};
use crate::island::{CLEAR, backdrop, faded, mix};
use crate::{theme, tray};
use crate::island::{CLEAR, faded, mix};
use crate::{surface, theme, tray};
// one round slot per workspace, inside the pill's inset, the pill pill::left() px from the monitor's edge,
// the tray pill::tray_gap() after it (config.rs: workspaces.*)
@@ -43,14 +43,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let workspaces = Workspaces::read();
let hover = Hover::read();
// hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs); the tray only goes with the whole bar
let (slide, tray_slide, switched, peeking) = {
let edge = Edge::read();
let shown = edge.shown(&monitor.name);
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, edge::hide_h()), edge::hidden_by(shown, 0.0, edge::hide_h()), edge.switched(&monitor.name), shown == 0.0)
};
// special workspaces (scratchpads) aren't places to switch to
let here: Vec<&Workspace> = workspaces
.list()
@@ -59,7 +51,19 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.filter(|workspace| !workspace.name().is_some_and(|name| name.starts_with("special")))
.collect();
let count = here.len() as f32;
/*
* hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs);
* the tray only goes with the whole bar. and how far each workspace has grown into the row:
* one hyprland just made for a switch grows over the slide
*/
let (slide, tray_slide, switched, peeking, grown) = {
let edge = Edge::read();
let shown = edge.shown(&monitor.name);
let grown: Vec<f32> = here.iter().map(|workspace| edge.grown(workspace.id())).collect();
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, edge::hide_h()), edge::hidden_by(shown, 0.0, edge::hide_h()), edge.switched(&monitor.name), shown == 0.0, grown)
};
let step = pill::slot() + pill::slot_gap();
// ease in and out, with a slight stretch in the middle like a drop of ink
@@ -77,10 +81,30 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
});
let left = gone.as_ref().map_or(0.0, |(_, _, left)| *left);
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0).max(0.0) * pill::slot_gap() + left * step;
// the window keeps the ghost's full room while it closes, so it isn't resized every frame
let room = if gone.is_some() { width + (1.0 - left) * step } else { width };
// each slot's x along the row: a new workspace takes its room as it grows, the ghost gives its room back as it closes
let sizes: Vec<f32> = grown.iter().map(|t| eased(*t)).collect();
let mut xs: Vec<f32> = Vec::with_capacity(here.len());
let (mut x, mut ghost_x) = (0.0, 0.0);
for index in 0..=here.len() {
if gone.as_ref().is_some_and(|(at, _, _)| *at == index) {
ghost_x = x;
x += left * step;
}
if let Some(size) = sizes.get(index) {
xs.push(x);
x += size * step;
}
}
let width = 2.0 * pill::inset() + (x - pill::slot_gap()).max(0.0);
// while slots grow or close the window keeps the room of all of them whole, so it isn't resized every frame
let changing = gone.is_some() || sizes.iter().any(|size| *size < 1.0);
let whole = here.len() as f32 + if gone.is_some() { 1.0 } else { 0.0 };
let room = if changing { 2.0 * pill::inset() + (whole * step - pill::slot_gap()).max(0.0) } else { width };
let window = |window_w: f32| {
LayerWindow::new()
@@ -110,14 +134,14 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
/*
* the focused workspace's accent is one disc under the row, so a change slides it from
* the workspace before to this one (edge.rs times it); the slots themselves stay clear.
* slots are placed by x in one stack, after a closing ghost by what's left of it
* slots are placed by x in one stack, a growing one centred in the room it has so far
*/
let theme = theme::now();
let slot_x = |index: usize| index as f32 * step + gone.as_ref().filter(|(at, _, _)| index >= *at).map_or(0.0, |_| left * step);
let slot_x = |index: usize| xs[index] - (1.0 - sizes[index]) * pill::slot() / 2.0;
let focused_at = here.iter().position(|workspace| workspace.focused());
let came_from = switched.and_then(|(from, t)| match &gone {
Some((at, _, _)) => Some((*at as f32 * step, t)),
Some(_) => Some((ghost_x, t)),
None => here.iter().position(|workspace| workspace.id() == from).map(|at| (slot_x(at), t)),
});
@@ -129,19 +153,19 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let stretch = came_from.map_or(0.0, |(_, t)| (t * std::f32::consts::PI).sin() * 10.0);
if switched.is_some() {
if switched.is_some() || changing {
amane::request_frame();
}
// the ghost: its number shrinking into the gap it leaves
if let Some((at, label, left)) = &gone {
if let Some((_, label, left)) = &gone {
slots.push(Box::new(
Rectangle::new()
.width(pill::slot())
.height(pill::slot())
.align_child(Center, Center)
.scale(*left)
.translate(*at as f32 * step - (1.0 - left) * pill::slot() / 2.0, 0.0)
.translate(ghost_x - (1.0 - left) * pill::slot() / 2.0, 0.0)
.child(Text::new(label.as_str()).size(text::body()).color(Color::blend(CLEAR, theme.dim, *left))),
));
}
@@ -176,7 +200,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.radius(Full)
.fill(fill)
.align_child(Center, Center)
.scale(1.0 + 0.08 * lit)
.scale(sizes[at] * (1.0 + 0.08 * lit))
.cursor(Pointer)
.on_hover(move |inside| hovering(id, inside))
.on_click(move |_| focus(id))
@@ -196,17 +220,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
layers.extend(slots);
let pill = Rectangle::new()
.width(width)
.height(island_pill::height())
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(pill::inset())
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Stack::new(layers).width(row_w).height(pill::slot()));
let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Stack::new(layers).width(row_w).height(pill::slot()));
let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar);
// clicks next to the pills reach the windows underneath
let mut regions = vec![InputArea { x: pill::left() as i32, y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: island_pill::height() as i32 }];