Files
amane-config/src/theme.rs
T
server2 54ca75b855 glass-base: a glass opacity setting and one place for the surface look
- shell.conf glass (0.05 to 1, default 1 = solid, as before): the
  surfaces' opacity; read live with the theme
- surface.rs: shell() draws the island, the idle pill and the workspace
  pill (fill, rim, shadow), so a design is one file; the default is the
  look so far
- see-through surfaces can't hide content under a veil, so with glass
  below 1 the open/close fades use .opacity() while they run
- README: the Hyprland layer rule that frosts what's behind the shell
  (namespace match is a full match: ^magic-.*$; ignore_alpha keeps the
  transparent margin around the island sharp)
- docs/design: the solid look and glass 0.5 with the rule, for review
2026-10-09 05:20:44 +02:00

217 lines
6.6 KiB
Rust

use std::cell::Cell;
use std::path::PathBuf;
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use std::{env, fs, thread};
use amane::{Animation, Blend, Color, Service};
use crate::parse;
/*
* the shell's colours. they come from the wallpaper: skwd-wall runs matugen with
* matugen/amane.colors, which writes ~/.config/amane/theme. ~/.config/amane/shell.conf
* says whether the open media card takes the song cover's colours instead, and how long
* a change cross-fades. without a theme file the shell keeps its original pink on purple
*/
#[derive(Clone, Copy, PartialEq)]
pub struct Colors {
pub accent: Color,
pub on_accent: Color,
pub accent_soft: Color,
pub flame: Color,
pub text: Color,
pub dim: Color,
pub outline: Color,
pub surface: Color,
pub surface_high: Color,
pub surface_low: Color,
}
const PINK_ON_PURPLE: Colors = Colors {
accent: Color::rgb(247, 168, 200),
on_accent: Color::rgb(40, 18, 32),
accent_soft: Color::rgb(150, 82, 122),
flame: Color::rgb(255, 228, 240),
text: Color::rgb(241, 236, 246),
dim: Color::rgba(205, 192, 218, 160),
outline: Color::rgba(255, 255, 255, 26),
surface: Color::rgb(24, 18, 30),
surface_high: Color::rgb(35, 26, 44),
surface_low: Color::rgb(13, 10, 18),
};
impl Colors {
// the background gradient's three stops, light to dark
pub fn surfaces(&self) -> [Color; 3] {
[self.surface_high, self.surface, self.surface_low]
}
}
// so the colours can ride one Animation, all ten fading together
impl Blend for Colors {
fn blend(from: Self, to: Self, amount: f32) -> Self {
let mix = |from: Color, to: Color| Color::blend(from, to, amount);
Self {
accent: mix(from.accent, to.accent),
on_accent: mix(from.on_accent, to.on_accent),
accent_soft: mix(from.accent_soft, to.accent_soft),
flame: mix(from.flame, to.flame),
text: mix(from.text, to.text),
dim: mix(from.dim, to.dim),
outline: mix(from.outline, to.outline),
surface: mix(from.surface, to.surface),
surface_high: mix(from.surface_high, to.surface_high),
surface_low: mix(from.surface_low, to.surface_low),
}
}
}
pub struct Theme {
colors: Animation<Colors>,
// the open media card and the equalizer take the cover's colours as their background
pub cover_colors: bool,
// how opaque the shell's surfaces are, 0.05 to 1; below 1 the desktop shows through
glass: f32,
}
impl Service for Theme {
fn new() -> Self {
Self {
colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false,
glass: 1.0,
}
}
/*
* 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.
* shell.conf on its own thread, the theme here; either change re-reads both
*/
fn listen() {
reload();
thread::spawn(|| follow("shell.conf"));
follow("theme");
}
}
// what was read last, (theme, shell.conf); none until the first read
static LAST: Mutex<Option<(String, 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("shell.conf"));
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
// a write that changes nothing redraws nothing
if last.as_ref() == Some(&read) {
return;
}
// the colours at startup show at once, later changes cross-fade
apply(&read.0, &read.1, last.is_none());
*last = Some(read);
}
fn read(name: &str) -> String {
fs::read_to_string(config_file(name)).unwrap_or_default()
}
// ~/.config/amane/<name>, or under $XDG_CONFIG_HOME
pub fn config_file(name: &str) -> PathBuf {
let config = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_default();
config.join("amane").join(name)
}
fn apply(theme: &str, config: &str, startup: bool) {
let mut colors = PINK_ON_PURPLE;
// a missing or broken line keeps that colour's default
for (key, value) in parse::settings(theme) {
let Some([red, green, blue]) = parse::rgb(&value) else {
continue;
};
let color = Color::rgb(red, green, blue);
match key.as_str() {
"accent" => colors.accent = color,
"on_accent" => colors.on_accent = color,
"accent_soft" => colors.accent_soft = color,
"flame" => colors.flame = color,
"text" => colors.text = color,
"dim" => colors.dim = color,
"outline" => colors.outline = color,
"surface" => colors.surface = color,
"surface_high" => colors.surface_high = color,
"surface_low" => colors.surface_low = color,
_ => {}
}
}
let settings = parse::settings(config);
let setting = |name: &str| settings.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str());
let fade = if startup { 0 } else { setting("transition_ms").and_then(|ms| ms.parse().ok()).unwrap_or(400) };
let mut theme = Theme::write();
theme.cover_colors = setting("cover_colors") == Some("true");
theme.glass = setting("glass").and_then(|glass| glass.parse::<f32>().ok()).map_or(1.0, |glass| glass.clamp(0.05, 1.0));
// a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value();
theme.colors = Animation::new(now).duration(Duration::from_millis(fade));
theme.colors.to(colors);
}
// the colours and glass of the frame being drawn, set by the view that draws it
thread_local! {
static NOW: Cell<Colors> = const { Cell::new(PINK_ON_PURPLE) };
static GLASS: Cell<f32> = const { Cell::new(1.0) };
}
/*
* a view's first line: everything it draws this frame uses these colours (and
* reading Theme redraws it when they change). says whether the media card takes the cover's
*/
pub fn begin() -> bool {
let theme = Theme::read();
NOW.set(theme.colors.value());
GLASS.set(theme.glass);
theme.cover_colors
}
pub fn now() -> Colors {
NOW.get()
}
// the surfaces' opacity this frame, 0.05 to 1
pub fn glass() -> f32 {
GLASS.get()
}