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).
This commit is contained in:
+52
-5
@@ -505,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)
|
||||
@@ -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<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(
|
||||
|
||||
+33
-2
@@ -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();
|
||||
|
||||
|
||||
@@ -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\
|
||||
|
||||
+39
-31
@@ -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();
|
||||
|
||||
|
||||
+43
-19
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user