diff --git a/src/config.rs b/src/config.rs index 7fd78d9..777a5f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -505,6 +505,17 @@ static VALUES: LazyLock>> = LazyLock::new(Default: // what was wrong in the file when it was last read, for the config window static ERRORS: Mutex> = 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 = 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(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) @@ -520,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 @@ -667,26 +698,42 @@ pub fn override_of(path: &str) -> Option { 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( diff --git a/src/edge.rs b/src/edge.rs index 8cb8192..be867f5 100644 --- a/src/edge.rs +++ b/src/edge.rs @@ -56,6 +56,9 @@ pub struct Edge { pops: HashMap<(Part, String), Instant>, switched: HashMap, + // workspaces hyprland made a moment ago (by switching to a new one), by id + created: HashMap, + // the monitor whose top edge the pointer rests on, and whether it's over a part of the bar at_edge: Option, 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::().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(); diff --git a/src/parse.rs b/src/parse.rs index 2d98cf3..0973cad 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -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\ diff --git a/src/theme.rs b/src/theme.rs index 1c48716..8a9b795 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -76,24 +76,41 @@ impl Blend for Colors { pub struct Theme { colors: Animation, + // 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> = Mutex::new(None); +// the theme file as read last; none until the first read +static LAST: Mutex> = 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(); diff --git a/src/workspaces.rs b/src/workspaces.rs index a15cda7..4e09c1a 100644 --- a/src/workspaces.rs +++ b/src/workspaces.rs @@ -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 = 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 = grown.iter().map(|t| eased(*t)).collect(); + let mut xs: Vec = 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))