commit e17361e59d082e68471a13c65c45456e797a92a5
Author: 1jehuang <[email protected]>
Date: Sun, 28 Dec 2025 07:35:04 -0800
Initial commit: Niri workspaces indicator for Waybar
Features:
- Real-time workspace display via niri IPC
- Color-coded window indicators by app type
- Claude Code detection (✳ icon and title)
- Codex, nvim, Chrome, Firefox, Discord, Spotify colors
- tmux session indicator (broken bar ¦)
- Process tree scanning for terminal app detection
- Workspace names shown when focused
- Empty unfocused workspaces hidden
Diffstat:
| A | .gitignore | | | 1 | + |
| A | Cargo.lock | | | 260 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | Cargo.toml | | | 14 | ++++++++++++++ |
| A | README.md | | | 53 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main.rs | | | 355 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
5 files changed, 683 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,260 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "niri-ipc"
+version = "25.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12bd9eb4e437f5282ce853cf66837658379cb537b4b6bae687da59246005329a"
+dependencies = [
+ "clap",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "niri-workspaces"
+version = "0.1.0"
+dependencies = [
+ "niri-ipc",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.145"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "niri-workspaces"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+niri-ipc = { version = "25.11.0", features = ["clap"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[profile.release]
+lto = true
+strip = true
+opt-level = 3
diff --git a/README.md b/README.md
@@ -0,0 +1,53 @@
+# niri-workspaces-rs
+
+A fast, event-driven workspace indicator for [Waybar](https://github.com/Alexays/Waybar) and [niri](https://github.com/YaLTeR/niri).
+
+## Features
+
+- **Visual bars** instead of numbers - each window is a colored bar
+- **Color-coded by app** - Chrome (Google colors), Discord (purple), Firefox (orange), nvim (green), Claude (orange), Codex (cyan)
+- **Terminal app detection** - detects Claude/Codex/nvim running inside terminals via /proc
+- **Focused window indicator** - █ for focused, ▌ for would-be-focused, | for others
+- **Dimmed unfocused workspaces** - colors are dimmed when workspace isn't focused
+- **Daemon mode** - persistent socket connection, no process spawning
+
+## Benchmarks
+
+| Metric | Value |
+|--------|-------|
+| Memory | 2.5 MB RSS |
+| CPU per update | ~1.1ms |
+| 80 rapid switches | ~90ms total CPU |
+
+### Comparison
+
+| Approach | CPU per update |
+|----------|---------------|
+| Bash script | ~340ms |
+| Rust + signal | ~12-14ms |
+| Rust daemon | **~1.1ms** |
+
+The daemon is **~10-12x faster** than the signal-based approach and **~300x faster** than the original bash script.
+
+## Installation
+
+```bash
+cargo build --release
+cp target/release/niri-workspaces ~/.config/waybar/
+```
+
+## Waybar Configuration
+
+```json
+"custom/workspaces": {
+ "exec": "/home/user/.config/waybar/niri-workspaces",
+ "return-type": "json",
+ "format": "{}"
+}
+```
+
+No `interval` or `signal` needed - the daemon outputs JSON whenever workspace/window events occur.
+
+## License
+
+MIT
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,355 @@
+use niri_ipc::socket::Socket;
+use niri_ipc::{Event, Request, Response, Window, Workspace};
+use std::collections::HashMap;
+use std::fs;
+
+fn main() {
+ loop {
+ if let Err(e) = run_daemon() {
+ eprintln!("Daemon error: {}, reconnecting in 1s...", e);
+ std::thread::sleep(std::time::Duration::from_secs(1));
+ }
+ }
+}
+
+fn run_daemon() -> Result<(), Box<dyn std::error::Error>> {
+ // Get initial state and output
+ let (workspaces, windows) = fetch_state()?;
+ output_status(&workspaces, &windows);
+
+ // Subscribe to event stream
+ let mut socket = Socket::connect()?;
+ let reply = socket.send(Request::EventStream)?;
+
+ match reply {
+ Ok(Response::Handled) => {}
+ Ok(other) => return Err(format!("Unexpected response: {:?}", other).into()),
+ Err(msg) => return Err(msg.into()),
+ }
+
+ let mut read_event = socket.read_events();
+
+ loop {
+ let event = read_event()?;
+
+ // Only update on relevant events
+ match event {
+ Event::WorkspacesChanged { .. }
+ | Event::WorkspaceActivated { .. }
+ | Event::WindowsChanged { .. }
+ | Event::WindowOpenedOrChanged { .. }
+ | Event::WindowClosed { .. }
+ | Event::WindowFocusChanged { .. } => {
+ if let Ok((ws, win)) = fetch_state() {
+ output_status(&ws, &win);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn fetch_state() -> Result<(Vec<Workspace>, Vec<Window>), Box<dyn std::error::Error>> {
+ let mut socket = Socket::connect()?;
+
+ let workspaces = match socket.send(Request::Workspaces)? {
+ Ok(Response::Workspaces(ws)) => ws,
+ Ok(other) => return Err(format!("Unexpected: {:?}", other).into()),
+ Err(msg) => return Err(msg.into()),
+ };
+
+ let mut socket = Socket::connect()?;
+ let windows = match socket.send(Request::Windows)? {
+ Ok(Response::Windows(ws)) => ws,
+ Ok(other) => return Err(format!("Unexpected: {:?}", other).into()),
+ Err(msg) => return Err(msg.into()),
+ };
+
+ Ok((workspaces, windows))
+}
+
+fn output_status(workspaces: &[Workspace], windows: &[Window]) {
+ // Pre-compute terminal apps.
+ // Avoid per-pid process detection for foot/footclient when multiple windows
+ // share the same pid, to prevent false positives across windows.
+ let mut pid_counts: HashMap<i32, usize> = HashMap::new();
+ for w in windows {
+ if let Some(pid) = w.pid {
+ *pid_counts.entry(pid).or_insert(0) += 1;
+ }
+ }
+
+ let terminal_apps: HashMap<i32, &str> = windows
+ .iter()
+ .filter(|w| is_terminal(w.app_id.as_deref().unwrap_or("")))
+ .filter_map(|w| {
+ let pid = w.pid?;
+ let app_id = w.app_id.as_deref().unwrap_or("");
+ if (app_id == "foot" || app_id == "footclient")
+ && pid_counts.get(&pid).copied().unwrap_or(0) > 1
+ {
+ return None;
+ }
+ Some((pid, detect_terminal_app(pid as u32)))
+ })
+ .filter(|(_, app)| !app.is_empty())
+ .collect();
+
+ let mut sorted_workspaces: Vec<&Workspace> = workspaces.iter().collect();
+ sorted_workspaces.sort_by_key(|w| w.idx);
+
+ let mut before_focused: Vec<String> = Vec::new();
+ let mut after_focused: Vec<String> = Vec::new();
+ let mut focused_output = String::new();
+ let mut current_section = "before";
+ let mut chrome_idx = 0;
+ let mut tooltip = String::new();
+
+ for ws in &sorted_workspaces {
+ let mut ws_windows: Vec<&Window> = windows
+ .iter()
+ .filter(|w| w.workspace_id == Some(ws.id))
+ .collect();
+
+ ws_windows.sort_by_key(|win| window_sort_key(*win));
+
+ let win_count = ws_windows.len();
+
+ // Get workspace name if it's not default/empty
+ let ws_name = ws.name.as_deref().unwrap_or("");
+ let has_custom_name = !ws_name.is_empty();
+
+ if has_custom_name {
+ tooltip.push_str(&format!("{}: {} windows\\n", ws_name, win_count));
+ } else {
+ tooltip.push_str(&format!("ws{}: {} windows\\n", ws.idx, win_count));
+ }
+
+ // Skip empty unfocused workspaces entirely
+ if win_count == 0 && !ws.is_focused {
+ continue;
+ }
+
+ let ws_output = if win_count == 0 {
+ // Empty but focused
+ if has_custom_name {
+ format!("<span color='#888888'>{}</span>", ws_name)
+ } else {
+ "<span color='#888888'>·</span>".to_string()
+ }
+ } else {
+ let mut output = String::new();
+
+ // Show workspace name only when focused
+ if has_custom_name && ws.is_focused {
+ output.push_str(&format!("<span color='#cccccc'>{}</span> ", ws_name));
+ }
+
+ for win in &ws_windows {
+ let app_id = win.app_id.as_deref().unwrap_or("");
+ let title = win.title.as_deref().unwrap_or("");
+ let mut color = get_color(app_id, title, win.pid, &terminal_apps);
+ let is_tmux = is_tmux_title(title);
+
+ if color == "chrome" {
+ const CHROME_COLORS: [&str; 4] = ["#ea4335", "#fbbc05", "#34a853", "#4285f4"];
+ color = CHROME_COLORS[chrome_idx % 4].to_string();
+ chrome_idx += 1;
+ }
+
+ if !ws.is_focused {
+ color = dim_color(&color);
+ }
+
+ let mut bar = if win.is_focused {
+ "█"
+ } else if !ws.is_focused && ws.active_window_id == Some(win.id) {
+ "▌"
+ } else {
+ "|"
+ };
+
+ // Use a broken bar for tmux without changing focus semantics.
+ if is_tmux && bar == "|" {
+ bar = "¦";
+ }
+
+ output.push_str(&format!("<span color='{}'>{}</span>", color, bar));
+ }
+ output
+ };
+
+ if ws.is_focused {
+ focused_output = ws_output;
+ current_section = "after";
+ } else if current_section == "before" {
+ before_focused.push(ws_output);
+ } else {
+ after_focused.push(ws_output);
+ }
+ }
+
+ let mut output = String::new();
+ for ws in &before_focused {
+ output.push_str(ws);
+ output.push_str(" ");
+ }
+ output.push_str(&focused_output);
+ for ws in &after_focused {
+ output.push_str(" ");
+ output.push_str(ws);
+ }
+
+ if tooltip.ends_with("\\n") {
+ tooltip.truncate(tooltip.len() - 2);
+ }
+
+ println!(r#"{{"text": "{}", "tooltip": "{}"}}"#, output, tooltip);
+}
+
+fn is_terminal(app_id: &str) -> bool {
+ app_id == "Alacritty"
+ || app_id == "kitty"
+ || app_id == "com.mitchellh.ghostty"
+ || app_id == "foot"
+ || app_id == "footclient"
+}
+
+fn is_tmux_title(title: &str) -> bool {
+ title.to_lowercase().contains("tmux")
+}
+
+fn is_claude_title(title: &str) -> bool {
+ let lower = title.to_lowercase();
+ lower.contains("claude") || title.contains("✳")
+}
+
+fn detect_terminal_app(pid: u32) -> &'static str {
+ let mut found_claude = false;
+ let mut found_codex = false;
+ let mut found_nvim = false;
+
+ if let Ok(children) = get_all_descendants(pid) {
+ for child_pid in children {
+ if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", child_pid)) {
+ let cmdline = cmdline.to_lowercase();
+ if cmdline.contains("claude") {
+ found_claude = true;
+ }
+ if cmdline.contains("codex") {
+ found_codex = true;
+ }
+ if cmdline.contains("nvim") || cmdline.contains("vim") {
+ found_nvim = true;
+ }
+ }
+ }
+ }
+
+ if found_claude {
+ "claude"
+ } else if found_codex {
+ "codex"
+ } else if found_nvim {
+ "nvim"
+ } else {
+ ""
+ }
+}
+
+fn get_all_descendants(pid: u32) -> std::io::Result<Vec<u32>> {
+ let mut descendants = Vec::new();
+ let mut to_check = vec![pid];
+
+ while let Some(current) = to_check.pop() {
+ let children_path = format!("/proc/{}/task/{}/children", current, current);
+ if let Ok(children_str) = fs::read_to_string(&children_path) {
+ for child in children_str.split_whitespace() {
+ if let Ok(child_pid) = child.parse::<u32>() {
+ descendants.push(child_pid);
+ to_check.push(child_pid);
+ }
+ }
+ }
+ }
+
+ Ok(descendants)
+}
+
+fn get_color(app_id: &str, title: &str, pid: Option<i32>, terminal_apps: &HashMap<i32, &str>) -> String {
+ if is_terminal(app_id) {
+ let title_lower = title.to_lowercase();
+
+ if is_claude_title(title) {
+ return "#f5a623".to_string();
+ } else if title_lower.contains("codex") {
+ return "#56b6c2".to_string();
+ } else if title_lower.contains("nvim") || title_lower.contains("vim") {
+ return "#98c379".to_string();
+ }
+
+ if let Some(pid) = pid {
+ if let Some(&app) = terminal_apps.get(&pid) {
+ return match app {
+ "claude" => "#f5a623",
+ "codex" => "#56b6c2",
+ "nvim" => "#98c379",
+ _ => "#888888",
+ }
+ .to_string();
+ }
+ }
+
+ return "#888888".to_string();
+ }
+
+ if app_id.contains("nvim") {
+ return "#98c379".to_string();
+ }
+ if app_id == "google-chrome" || app_id.contains("chrome") {
+ return "chrome".to_string();
+ }
+ if app_id == "firefox" {
+ return "#ff7139".to_string();
+ }
+ if app_id == "vesktop" || app_id == "discord" {
+ return "#c678dd".to_string();
+ }
+ if app_id == "spotify" || app_id.contains("spotify") {
+ return "#1DB954".to_string();
+ }
+ if app_id.contains("todoist") {
+ return "#e06c75".to_string();
+ }
+ if app_id.contains("mail.google") {
+ return "#e5c07b".to_string();
+ }
+
+ "#666666".to_string()
+}
+
+fn dim_color(hex: &str) -> String {
+ let hex = hex.trim_start_matches('#');
+ if hex.len() != 6 {
+ return format!("#{}", hex);
+ }
+
+ let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
+ let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
+ let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
+
+ let r = ((r as u32 * 6 / 10) + 30).min(255) as u8;
+ let g = ((g as u32 * 6 / 10) + 30).min(255) as u8;
+ let b = ((b as u32 * 6 / 10) + 30).min(255) as u8;
+
+ format!("#{:02x}{:02x}{:02x}", r, g, b)
+}
+
+fn window_sort_key(win: &Window) -> (u8, u64, u64, u64) {
+ if let Some((col, row)) = win.layout.pos_in_scrolling_layout {
+ (0, col as u64, row as u64, win.id)
+ } else {
+ // Floating/unknown layout windows go after tiled windows.
+ (1, 0, 0, win.id)
+ }
+}