summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-21 14:07:42 +0200
committercashmere <cashmere@autistici.org>2026-07-21 14:07:42 +0200
commit198f374a18b5c8a0e21f7d41125393aa89523f5c (patch)
treead8d32cc498b1b2715a5e71e5f438bf4bacfca53
parent85f0a9e875eee768766d7bdd24272d348f6b1e33 (diff)
downloadi3bar-mango-198f374a18b5c8a0e21f7d41125393aa89523f5c.tar.gz
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
-rw-r--r--.justfile2
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml1
-rw-r--r--src/bar.rs64
-rw-r--r--src/config.rs2
-rw-r--r--src/icon.rs137
-rw-r--r--src/main.rs1
-rw-r--r--src/shared_state.rs2
-rw-r--r--src/state.rs13
-rw-r--r--src/wm_info_provider.rs9
-rw-r--r--src/wm_info_provider/mango.rs81
11 files changed, 306 insertions, 7 deletions
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 <maxxverrr@gmail.com>"]
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<Tag>,
layout_name: Option<String>,
mode_name: Option<String>,
+ focused_window: Option<FocusedWindow>,
tags_btns: ButtonManager<u32>,
tags_computed: Vec<(u32, ColorPair, ComputedText)>,
layout_name_computed: Option<ComputedText>,
mode_computed: Option<ComputedText>,
+ focused_window_computed: Option<ComputedText>,
}
#[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<FocusedWindow>) {
+ self.focused_window = focused_window;
+ self.focused_window_computed = None;
+ }
+
pub fn click(
&mut self,
conn: &mut Connection<State>,
@@ -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<String, Option<cairo::ImageSurface>>,
+}
+
+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<cairo::ImageSurface> {
+ 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 `<app_id>.desktop` in the applications
+/// directories of $XDG_DATA_HOME and $XDG_DATA_DIRS.
+fn desktop_file_icon(app_id: &str) -> Option<String> {
+ 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 `<name>.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<PathBuf> {
+ 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::<u32>() 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<PathBuf> {
+ 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<StatusCmd>,
pub blocks_cache: BlocksCache,
+ pub icon_cache: IconCache,
pub wm_info_provider: Box<dyn WmInfoProvider>,
}
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<Self>, output: Option<WlOutput>) {
+ 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<Self>, output: Option<WlOutput>) {
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<String> {
None
}
+ fn get_focused_window(&self, _: &Output) -> Option<FocusedWindow> {
+ None
+ }
fn click_on_tag(
&mut self,
@@ -83,6 +86,12 @@ pub fn bind(conn: &mut Connection<State>, config: &WmConfig) -> Box<dyn WmInfoPr
Box::new(DummyInfoProvider)
}
+#[derive(Debug, Clone, PartialEq)]
+pub struct FocusedWindow {
+ pub app_id: String,
+ pub title: String,
+}
+
#[derive(Debug)]
pub struct Tag {
pub id: u32,
diff --git a/src/wm_info_provider/mango.rs b/src/wm_info_provider/mango.rs
index fe5759e..5476f5e 100644
--- a/src/wm_info_provider/mango.rs
+++ b/src/wm_info_provider/mango.rs
@@ -29,6 +29,18 @@ struct MangoMonitor {
active: bool,
tags: Vec<MangoTag>,
layout_symbol: String,
+ #[serde(default)]
+ active_client: Option<MangoActiveClient>,
+}
+
+// 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<u32>,
+ title: Option<String>,
+ appid: Option<String>,
}
@@ -40,7 +52,7 @@ struct MangoIpc {
impl MangoInfoProvider {
pub fn new() -> Option<Self> {
- 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<String> {
- // 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<String> {
None
}
+ fn get_focused_window(&self, output: &Output) -> Option<FocusedWindow> {
+ 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<State>,
+ _conn: &mut Connection<State>,
_output: &Output,
_seat: WlSeat,
tag_id: Option<u32>,
_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());
+ }
+}