niri-workspaces-rs

a better way to visualise workspace windows in niri window manager
Log | Files | Refs | README

commit 35e9dbabcab936ecd1792f7901e3fb9b0223d8a1
parent e17361e59d082e68471a13c65c45456e797a92a5
Author: jeremy <[email protected]>
Date:   Tue, 24 Mar 2026 10:16:20 -0700

Improve workspace rendering and docs

Diffstat:
MREADME.md | 24++++++++++++++++--------
Msrc/main.rs | 41+++++++++++++++++++++++++++--------------
2 files changed, 43 insertions(+), 22 deletions(-)

diff --git a/README.md b/README.md @@ -2,14 +2,20 @@ A fast, event-driven workspace indicator for [Waybar](https://github.com/Alexays/Waybar) and [niri](https://github.com/YaLTeR/niri). +<img width="2880" height="1800" alt="image" src="https://github.com/user-attachments/assets/45c689b4-2c0d-4faf-8033-e34341cad8c2" /> + ## 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 +- **Visual bars instead of numbers** — each window is rendered as a colored bar. +- **Event-driven daemon mode** — keeps a persistent niri socket connection and emits JSON updates only when workspace/window state changes. +- **App-aware coloring** — built-in colors for Chrome, Firefox, Discord/Vesktop, Spotify, Todoist, Gmail, and terminals. +- **Terminal app detection via `/proc`** — detects `jcode`, Claude, Codex, and Neovim/Vim running inside terminal windows. +- **Terminal support** — works with Alacritty, Kitty, Ghostty, Foot, and Footclient. +- **Focus semantics** — uses `█` for the focused window, `▌` for the active window on an unfocused workspace, and `|` for other windows. +- **tmux hinting** — uses `¦` for non-focused tmux windows. +- **Cleaner empty workspace handling** — unfocused empty workspaces are hidden; a focused empty workspace is shown as a dim `·`. +- **Named workspace support** — shows the custom workspace name when that workspace is focused; tooltips include only non-empty workspaces. +- **Safer terminal PID detection** — skips `/proc` descendant inference for shared terminal PIDs in cases that can otherwise cause false positives. ## Benchmarks @@ -36,17 +42,19 @@ cargo build --release cp target/release/niri-workspaces ~/.config/waybar/ ``` +If Waybar is already running, restart Waybar or reload the module so the new binary is picked up. + ## Waybar Configuration ```json "custom/workspaces": { - "exec": "/home/user/.config/waybar/niri-workspaces", + "exec": "~/.config/waybar/niri-workspaces", "return-type": "json", "format": "{}" } ``` -No `interval` or `signal` needed - the daemon outputs JSON whenever workspace/window events occur. +No `interval` or `signal` is needed — the daemon outputs JSON whenever relevant niri events occur. ## License diff --git a/src/main.rs b/src/main.rs @@ -85,7 +85,7 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { .filter_map(|w| { let pid = w.pid?; let app_id = w.app_id.as_deref().unwrap_or(""); - if (app_id == "foot" || app_id == "footclient") + if (app_id == "foot" || app_id == "footclient" || app_id == "kitty") && pid_counts.get(&pid).copied().unwrap_or(0) > 1 { return None; @@ -119,10 +119,13 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { 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)); + // Only add to tooltip if workspace has windows + if win_count > 0 { + 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 @@ -131,12 +134,8 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { } 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() - } + // Empty but focused - just show a dot, no name + "<span color='#888888'>·</span>".to_string() } else { let mut output = String::new(); @@ -227,12 +226,16 @@ fn is_claude_title(title: &str) -> bool { fn detect_terminal_app(pid: u32) -> &'static str { let mut found_claude = false; let mut found_codex = false; + let mut found_jcode = 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("jcode") { + found_jcode = true; + } if cmdline.contains("claude") { found_claude = true; } @@ -246,7 +249,9 @@ fn detect_terminal_app(pid: u32) -> &'static str { } } - if found_claude { + if found_jcode { + "jcode" + } else if found_claude { "claude" } else if found_codex { "codex" @@ -276,11 +281,18 @@ fn get_all_descendants(pid: u32) -> std::io::Result<Vec<u32>> { Ok(descendants) } -fn get_color(app_id: &str, title: &str, pid: Option<i32>, terminal_apps: &HashMap<i32, &str>) -> String { +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) { + if title_lower.contains("jcode") { + return "#999999".to_string(); + } else if is_claude_title(title) { return "#f5a623".to_string(); } else if title_lower.contains("codex") { return "#56b6c2".to_string(); @@ -291,6 +303,7 @@ fn get_color(app_id: &str, title: &str, pid: Option<i32>, terminal_apps: &HashMa if let Some(pid) = pid { if let Some(&app) = terminal_apps.get(&pid) { return match app { + "jcode" => "#999999", "claude" => "#f5a623", "codex" => "#56b6c2", "nvim" => "#98c379",