From 198f374a18b5c8a0e21f7d41125393aa89523f5c Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 21 Jul 2026 14:07:42 +0200 Subject: mango: show focused window next to tags, fix mmsg lookup, 0.15.x compat - new focused-window block ({icon} {app_id} - {title}) driven by the active_client field of mmsg watch all-monitors (mangowm >= 0.14) - resolve program icons via freedesktop icon themes/.desktop files (new icon module, cairo png feature, cached per app_id) - new show_focused_window config option (default true) - run mmsg from PATH instead of hardcoded /usr/bin/mmsg (broken on NixOS) - handle null active_client fields (no focused client) without failing to parse the whole event - drop stale TODO comment and unused variable in mango provider - add .justfile run recipe exporting MANGO_INSTANCE_SIGNATURE --- .justfile | 2 + Cargo.lock | 1 + Cargo.toml | 1 + src/bar.rs | 64 +++++++++++++++++++- src/config.rs | 2 + src/icon.rs | 137 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/shared_state.rs | 2 + src/state.rs | 13 ++++ src/wm_info_provider.rs | 9 +++ src/wm_info_provider/mango.rs | 81 +++++++++++++++++++++++-- 11 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 .justfile create mode 100644 src/icon.rs diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..ef8851a --- /dev/null +++ b/.justfile @@ -0,0 +1,2 @@ +run: + MANGO_INSTANCE_SIGNATURE=$(ls $XDG_RUNTIME_DIR/mango-*.sock | head -n1) cargo run diff --git a/Cargo.lock b/Cargo.lock index 57516ca..2156e01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,7 @@ name = "i3bar-river" version = "1.1.0" dependencies = [ "anyhow", + "cairo-rs", "clap", "libc", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 45a389d..36d4d93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ authors = ["MaxVerevkin "] anyhow = "1" memchr = "2" pangocairo = "0.20" +cairo-rs = { version = "0.20", features = ["png"] } serde_json = "1" serde = { version = "1", features = ["derive"] } signal-hook = { version = "0.3", default-features = false } diff --git a/src/bar.rs b/src/bar.rs index 237c46a..102978d 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -14,7 +14,7 @@ use crate::protocol::*; use crate::shared_state::SharedState; use crate::state::State; use crate::text::{self, ComputedText, RenderOptions}; -use crate::wm_info_provider::Tag; +use crate::wm_info_provider::{FocusedWindow, Tag}; pub struct Bar { pub output: Output, @@ -33,10 +33,12 @@ pub struct Bar { tags: Vec, layout_name: Option, mode_name: Option, + focused_window: Option, tags_btns: ButtonManager, tags_computed: Vec<(u32, ColorPair, ComputedText)>, layout_name_computed: Option, mode_computed: Option, + focused_window_computed: Option, } #[derive(Debug, PartialEq)] @@ -79,10 +81,12 @@ impl Bar { tags: Vec::new(), layout_name: None, mode_name: None, + focused_window: None, tags_btns: Default::default(), tags_computed: Vec::new(), layout_name_computed: None, mode_computed: None, + focused_window_computed: None, } } @@ -112,6 +116,11 @@ impl Bar { self.mode_computed = None; } + pub fn set_focused_window(&mut self, focused_window: Option) { + self.focused_window = focused_window; + self.focused_window_computed = None; + } + pub fn click( &mut self, conn: &mut Connection, @@ -287,6 +296,59 @@ impl Bar { } } + // Display focused window + if ss.config.show_focused_window + && let Some(focused_window) = &self.focused_window + { + if let Some(icon) = ss.icon_cache.get(&focused_window.app_id, self.height) { + let icon_size = height_f - 4.0; + let scale = icon_size / (icon.width() as f64).max(icon.height() as f64); + let icon_w = icon.width() as f64 * scale; + let icon_h = icon.height() as f64 * scale; + cairo_ctx.save().unwrap(); + cairo_ctx.translate( + offset_left + (icon_size - icon_w) / 2.0, + (height_f - icon_h) / 2.0, + ); + cairo_ctx.scale(scale, scale); + cairo_ctx.set_source_surface(icon, 0.0, 0.0).unwrap(); + cairo_ctx.paint().unwrap(); + cairo_ctx.restore().unwrap(); + offset_left += icon_size + 5.0; + } + let text = self.focused_window_computed.get_or_insert_with(|| { + let label = if focused_window.title.is_empty() { + focused_window.app_id.clone() + } else { + format!("{} - {}", focused_window.app_id, focused_window.title) + }; + ComputedText::new( + &label, + text::Attributes { + font: &ss.config.font, + padding_left: 10.0, + padding_right: 10.0, + min_width: None, + align: Default::default(), + markup: false, + }, + ) + }); + text.render( + &cairo_ctx, + RenderOptions { + x_offset: offset_left, + bar_height: height_f, + fg_color: ss.config.tag_inactive_fg, + bg_color: None, + r_left: 0.0, + r_right: 0.0, + overlap: 0.0, + }, + ); + offset_left += text.width; + } + // Display mode if ss.config.show_mode { if let Some(mode) = &self.mode_name { diff --git a/src/config.rs b/src/config.rs index f54e2ed..e5c04e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,6 +46,7 @@ pub struct Config { pub invert_touchpad_scrolling: bool, pub show_tags: bool, pub show_layout_name: bool, + pub show_focused_window: bool, pub blend: bool, pub show_mode: bool, pub start_hidden: bool, @@ -92,6 +93,7 @@ impl Default for Config { invert_touchpad_scrolling: true, show_tags: true, show_layout_name: true, + show_focused_window: true, blend: true, show_mode: true, start_hidden: false, diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..4dae1a9 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,137 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use pangocairo::cairo; + +#[derive(Default)] +pub struct IconCache { + map: HashMap>, +} + +impl IconCache { + /// Get the icon for an app_id, best suited for the given bar height. + /// Results (including misses) are cached per app_id. + pub fn get(&mut self, app_id: &str, bar_height: u32) -> Option<&cairo::ImageSurface> { + self.map + .entry(app_id.to_string()) + .or_insert_with(|| resolve_icon(app_id, bar_height)) + .as_ref() + } +} + +fn resolve_icon(app_id: &str, bar_height: u32) -> Option { + let mut candidates = vec![app_id.to_string()]; + if let Some(desktop_icon) = desktop_file_icon(app_id) + && !candidates.contains(&desktop_icon) + { + candidates.push(desktop_icon); + } + for name in candidates { + if let Some(path) = find_icon_png(&name, bar_height) + && let Ok(mut file) = std::fs::File::open(path) + && let Ok(surface) = cairo::ImageSurface::create_from_png(&mut file) + { + return Some(surface); + } + } + None +} + +/// Look up the `Icon=` value of `.desktop` in the applications +/// directories of $XDG_DATA_HOME and $XDG_DATA_DIRS. +fn desktop_file_icon(app_id: &str) -> Option { + let file_name = format!("{app_id}.desktop"); + for base in std::iter::once(data_home()).chain(data_dirs()) { + let path = base.join("applications").join(&file_name); + if let Ok(contents) = std::fs::read_to_string(path) { + let mut in_desktop_entry = false; + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_desktop_entry = line == "[Desktop Entry]"; + } else if in_desktop_entry + && let Some(icon) = line.strip_prefix("Icon=") + { + return Some(icon.trim().to_string()); + } + } + } + } + None +} + +/// Search for `.png` in the icon themes and pixmaps directories. +/// Picks the smallest icon >= bar_height, else the largest available. +fn find_icon_png(name: &str, bar_height: u32) -> Option { + let file_name = format!("{name}.png"); + let mut best: Option<(u32, PathBuf)> = None; + + let base_dirs = std::iter::once(data_home()) + .chain(std::iter::once(home_dir().join(".icons"))) + .chain(data_dirs()); + + for base in base_dirs { + let pixmap = base.join("pixmaps").join(&file_name); + if pixmap.is_file() { + consider(&mut best, 0, pixmap, bar_height); + } + let Ok(themes) = std::fs::read_dir(base.join("icons")) else { + continue; + }; + for theme in themes.flatten() { + let Ok(size_dirs) = std::fs::read_dir(theme.path()) else { + continue; + }; + for size_dir in size_dirs.flatten() { + let dir_name = size_dir.file_name(); + let Some(dir_name) = dir_name.to_str() else { + continue; + }; + // "48x48" or "48x48@2x"; skip "scalable" (cairo can't load SVG) + let Ok(size) = dir_name.split('x').next().unwrap().parse::() else { + continue; + }; + let path = size_dir.path().join("apps").join(&file_name); + if path.is_file() { + consider(&mut best, size, path, bar_height); + } + } + } + } + + best.map(|(_, path)| path) +} + +/// Keep the candidate that is the smallest >= bar_height, else the largest. +fn consider(best: &mut Option<(u32, PathBuf)>, size: u32, path: PathBuf, bar_height: u32) { + let better = match best { + None => true, + Some((best_size, _)) => match (size >= bar_height, *best_size >= bar_height) { + (true, true) => size < *best_size, + (true, false) => true, + (false, true) => false, + (false, false) => size > *best_size, + }, + }; + if better { + *best = Some((size, path)); + } +} + +fn data_home() -> PathBuf { + match std::env::var_os("XDG_DATA_HOME") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => home_dir().join(".local/share"), + } +} + +fn data_dirs() -> Vec { + match std::env::var_os("XDG_DATA_DIRS") { + Some(dirs) if !dirs.is_empty() => std::env::split_paths(&dirs).collect(), + _ => vec!["/usr/local/share".into(), "/usr/share".into()], + } +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME").map_or_else(|| PathBuf::from("/"), PathBuf::from) +} diff --git a/src/main.rs b/src/main.rs index 4a86593..b43443c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod color; mod config; mod event_loop; mod i3bar_protocol; +mod icon; mod output; mod pointer_btn; mod protocol; diff --git a/src/shared_state.rs b/src/shared_state.rs index 17752d7..c484d24 100644 --- a/src/shared_state.rs +++ b/src/shared_state.rs @@ -3,6 +3,7 @@ use std::any::Any; use crate::{ blocks_cache::BlocksCache, config::Config, + icon::IconCache, status_cmd::StatusCmd, wm_info_provider::{self, WmInfoProvider}, }; @@ -14,6 +15,7 @@ pub struct SharedState { pub config: Config, pub status_cmd: Option, pub blocks_cache: BlocksCache, + pub icon_cache: IconCache, pub wm_info_provider: Box, } diff --git a/src/state.rs b/src/state.rs index 263f38c..ccd0d52 100644 --- a/src/state.rs +++ b/src/state.rs @@ -103,6 +103,7 @@ impl State { config, status_cmd, blocks_cache: BlocksCache::default(), + icon_cache: Default::default(), wm_info_provider, }, @@ -160,6 +161,11 @@ impl State { let mut bar = Bar::new(conn, self, output); bar.set_tags(self.shared_state.wm_info_provider.get_tags(&bar.output)); + bar.set_focused_window( + self.shared_state + .wm_info_provider + .get_focused_window(&bar.output), + ); if !self.hidden { bar.show(conn, &self.shared_state); @@ -221,6 +227,13 @@ impl State { }); } + pub fn focused_window_updated(&mut self, conn: &mut Connection, output: Option) { + self.for_each_bar(output, |bar, ss| { + bar.set_focused_window(ss.wm_info_provider.get_focused_window(&bar.output)); + bar.frame(conn, ss); + }); + } + pub fn mode_name_updated(&mut self, conn: &mut Connection, output: Option) { self.for_each_bar(output, |bar, ss| { bar.set_mode_name(ss.wm_info_provider.get_mode_name(&bar.output)); diff --git a/src/wm_info_provider.rs b/src/wm_info_provider.rs index d598234..67e5f83 100644 --- a/src/wm_info_provider.rs +++ b/src/wm_info_provider.rs @@ -47,6 +47,9 @@ pub trait WmInfoProvider: Any { fn get_mode_name(&self, _: &Output) -> Option { None } + fn get_focused_window(&self, _: &Output) -> Option { + None + } fn click_on_tag( &mut self, @@ -83,6 +86,12 @@ pub fn bind(conn: &mut Connection, config: &WmConfig) -> Box, layout_symbol: String, + #[serde(default)] + active_client: Option, +} + +// When no client is focused, the compositor sends +// {"id":null,"title":null,"appid":null} instead of omitting the field. +#[derive(serde::Deserialize, Debug, Clone)] +struct MangoActiveClient { + #[allow(dead_code)] + id: Option, + title: Option, + appid: Option, } @@ -40,7 +52,7 @@ struct MangoIpc { impl MangoInfoProvider { pub fn new() -> Option { - let child = Command::new("/usr/bin/mmsg") + let child = Command::new("mmsg") .arg("watch") .arg("all-monitors") .stdout(Stdio::piped()) @@ -80,6 +92,7 @@ impl WmInfoProvider for MangoInfoProvider { loop { let mango = ctx.state.shared_state.get_mango().unwrap(); let mut layout_updated = false; + let focused_window_updated; match mango.next_event() { Ok(event) => { if let Some(i) = event.monitors.iter().position(|m| m.active) { @@ -87,6 +100,15 @@ impl WmInfoProvider for MangoInfoProvider { layout_updated = true; } } + focused_window_updated = event.monitors.iter().any(|new_monitor| { + let old_client = mango + .all_monitors + .iter() + .find(|m| m.name == new_monitor.name) + .and_then(|m| m.active_client.as_ref()); + old_client.map(|c| (&c.title, &c.appid)) + != new_monitor.active_client.as_ref().map(|c| (&c.title, &c.appid)) + }); mango.all_monitors = event.monitors; } Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, @@ -99,6 +121,9 @@ impl WmInfoProvider for MangoInfoProvider { if layout_updated { ctx.state.layout_name_updated(ctx.conn, None); } + if focused_window_updated { + ctx.state.focused_window_updated(ctx.conn, None); + } } Ok(event_loop::Action::Keep) }); @@ -107,7 +132,8 @@ impl WmInfoProvider for MangoInfoProvider { self.all_monitors .iter() .find(|c| c.name == output.name) - .map_or_default( + .map_or_else( + Vec::new, |monitor| { monitor .tags @@ -124,9 +150,7 @@ impl WmInfoProvider for MangoInfoProvider { }, ) } - // TODO fn get_layout_name(&self, output: &Output) -> Option { - // self.layout_symbol.clone() self.all_monitors .iter() .find(|c| c.name == output.name) @@ -142,17 +166,29 @@ impl WmInfoProvider for MangoInfoProvider { fn get_mode_name(&self, _: &Output) -> Option { None } + fn get_focused_window(&self, output: &Output) -> Option { + self.all_monitors + .iter() + .find(|c| c.name == output.name) + .and_then(|monitor| monitor.active_client.as_ref()) + .and_then(|client| { + Some(FocusedWindow { + app_id: client.appid.clone()?, + title: client.title.clone().unwrap_or_default(), + }) + }) + } fn click_on_tag( &mut self, - conn: &mut Connection, + _conn: &mut Connection, _output: &Output, _seat: WlSeat, tag_id: Option, _btn: PointerBtn, ) { if let Some(id) = tag_id { - let _ = Command::new("/usr/bin/mmsg") + let _ = Command::new("mmsg") .arg("dispatch") .arg(format!("view,{}", id)) .stdout(Stdio::piped()) @@ -160,3 +196,36 @@ impl WmInfoProvider for MangoInfoProvider { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Real output shape of `mmsg watch all-monitors` (mangowm 0.15.4) + #[test] + fn parse_all_monitors_event() { + let event = r#"{"monitors":[{"name":"DP-1","active":true,"is_hdr":false,"is_vrr":false,"x":1920,"y":0,"width":1920,"height":1080,"scale":1,"layout_index":0,"layout_symbol":"T","last_open_surface":"i3bar-river","tags":[{"index":1,"is_active":true,"is_urgent":false,"layout":"T","client_count":1}],"active_tags":[1],"active_client":{"id":3,"title":"some title","appid":"emacs"},"keymode":"default","keyboardlayout":"English (US)"}]}"#; + let ipc: MangoIpc = serde_json::from_str(event).unwrap(); + let client = ipc.monitors[0].active_client.as_ref().unwrap(); + assert_eq!(client.appid.as_deref(), Some("emacs")); + assert_eq!(client.title.as_deref(), Some("some title")); + } + + // With no focused client the compositor sends nulls instead of omitting the field + #[test] + fn parse_all_monitors_event_no_focused_client() { + let event = r#"{"monitors":[{"name":"DP-1","active":true,"layout_symbol":"T","tags":[{"index":1,"is_active":false,"is_urgent":false,"client_count":0}],"active_client":{"id":null,"title":null,"appid":null}}]}"#; + let ipc: MangoIpc = serde_json::from_str(event).unwrap(); + let client = ipc.monitors[0].active_client.as_ref().unwrap(); + assert_eq!(client.appid, None); + assert_eq!(client.title, None); + } + + // Older mangowc versions (<0.14.x) have no active_client field at all + #[test] + fn parse_all_monitors_event_without_active_client() { + let event = r#"{"monitors":[{"name":"DP-1","active":true,"layout_symbol":"T","tags":[{"index":1,"is_active":false,"is_urgent":false,"client_count":0}]}]}"#; + let ipc: MangoIpc = serde_json::from_str(event).unwrap(); + assert!(ipc.monitors[0].active_client.is_none()); + } +} -- cgit v1.3.1