From e7a279d0e5bb7d80a85546929edf0f0863e7480d Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Fri, 19 Dec 2025 03:57:33 -0800 Subject: Initial commit: niri workspaces waybar module in Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast workspace visualization for waybar with: - Color-coded windows by app (Chrome, Discord, Firefox, etc.) - Terminal detection (Claude, Codex, nvim) via /proc - Dimmed colors for unfocused workspaces - Active window indicator (▌) for would-be-focused windows - Parallel niri IPC calls for ~12ms execution time --- .gitignore | 1 + Cargo.lock | 107 ++++++++++++++++++++++++++ Cargo.toml | 13 ++++ src/main.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fda0e4e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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-workspaces" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[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 = "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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9a25c76 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "niri-workspaces" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +lto = true +strip = true +opt-level = 3 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..eb420de --- /dev/null +++ b/src/main.rs @@ -0,0 +1,248 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::process::Command; + +#[derive(Deserialize)] +struct Workspace { + id: i64, + idx: i32, + is_focused: bool, + active_window_id: Option, +} + +#[derive(Deserialize)] +struct Window { + id: i64, + workspace_id: i64, + app_id: String, + title: String, + is_focused: bool, + pid: Option, +} + +fn main() { + // Get data from niri in parallel + let ws_handle = std::thread::spawn(|| { + Command::new("niri") + .args(["msg", "-j", "workspaces"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + }); + + let win_handle = std::thread::spawn(|| { + Command::new("niri") + .args(["msg", "-j", "windows"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + }); + + let (workspaces_json, windows_json) = match (ws_handle.join().ok().flatten(), win_handle.join().ok().flatten()) { + (Some(w), Some(win)) => (w, win), + _ => { + println!(r#"{{"text": "", "tooltip": "niri not running"}}"#); + return; + } + }; + + let mut workspaces: Vec = serde_json::from_str(&workspaces_json).unwrap_or_default(); + let windows: Vec = serde_json::from_str(&windows_json).unwrap_or_default(); + + workspaces.sort_by_key(|w| w.idx); + + // Pre-compute terminal apps by checking /proc + let terminal_apps: HashMap = windows + .iter() + .filter(|w| is_terminal(&w.app_id)) + .filter_map(|w| w.pid) + .map(|pid| (pid, detect_terminal_app(pid))) + .filter(|(_, app)| !app.is_empty()) + .collect(); + + let mut before_focused: Vec = Vec::new(); + let mut after_focused: Vec = 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 &workspaces { + let mut ws_windows: Vec<&Window> = windows + .iter() + .filter(|w| w.workspace_id == ws.id) + .collect(); + + ws_windows.sort_by_key(|w| w.id); + + let win_count = ws_windows.len(); + tooltip.push_str(&format!("ws{}: {} windows\\n", ws.idx, win_count)); + + let ws_output = if win_count == 0 { + if ws.is_focused { + "".to_string() + } else { + "·".to_string() + } + } else { + let mut output = String::new(); + for win in &ws_windows { + let mut color = get_color(&win.app_id, &win.title, win.pid, &terminal_apps); + + 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 bar = if win.is_focused { + "█" + } else if !ws.is_focused && Some(win.id) == ws.active_window_id { + "▌" + } else { + "|" + }; + + output.push_str(&format!("{}", 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" +} + +fn detect_terminal_app(pid: u32) -> &'static str { + 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") { + return "claude"; + } else if cmdline.contains("codex") { + return "codex"; + } else if cmdline.contains("nvim") || cmdline.contains("vim") { + return "nvim"; + } + } + } + } + "" +} + +fn get_all_descendants(pid: u32) -> std::io::Result> { + 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::() { + descendants.push(child_pid); + to_check.push(child_pid); + } + } + } + } + + Ok(descendants) +} + +fn get_color(app_id: &str, title: &str, pid: Option, terminal_apps: &HashMap) -> String { + if is_terminal(app_id) { + let title_lower = title.to_lowercase(); + + if title_lower.contains("claude") { + 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.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) +} -- cgit v1.3 From 1569735cdc71dbb74da49b64144d95a3f451a9a6 Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Fri, 19 Dec 2025 04:07:39 -0800 Subject: Implement daemon mode with persistent niri socket connection - Uses niri-ipc event stream for real-time updates - No signal/interval needed - outputs JSON on relevant events - Auto-reconnects on socket errors - Near-zero CPU when idle --- Cargo.lock | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/main.rs | 132 +++++++++++++++++++++++++++++---------------------- 3 files changed, 231 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fda0e4e..36fc2c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,114 @@ # 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" @@ -14,14 +122,32 @@ 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" @@ -89,6 +215,12 @@ dependencies = [ "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" @@ -105,3 +237,24 @@ 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 index 9a25c76..7454eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +niri-ipc = { version = "25.11.0", features = ["clap"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/main.rs b/src/main.rs index eb420de..7a8a1a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,66 +1,85 @@ -use serde::Deserialize; +use niri_ipc::socket::Socket; +use niri_ipc::{Event, Request, Response, Window, Workspace}; use std::collections::HashMap; use std::fs; -use std::process::Command; - -#[derive(Deserialize)] -struct Workspace { - id: i64, - idx: i32, - is_focused: bool, - active_window_id: Option, -} -#[derive(Deserialize)] -struct Window { - id: i64, - workspace_id: i64, - app_id: String, - title: String, - is_focused: bool, - pid: Option, +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 main() { - // Get data from niri in parallel - let ws_handle = std::thread::spawn(|| { - Command::new("niri") - .args(["msg", "-j", "workspaces"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - }); - - let win_handle = std::thread::spawn(|| { - Command::new("niri") - .args(["msg", "-j", "windows"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - }); - - let (workspaces_json, windows_json) = match (ws_handle.join().ok().flatten(), win_handle.join().ok().flatten()) { - (Some(w), Some(win)) => (w, win), - _ => { - println!(r#"{{"text": "", "tooltip": "niri not running"}}"#); - return; +fn run_daemon() -> Result<(), Box> { + // 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, Vec), Box> { + 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 workspaces: Vec = serde_json::from_str(&workspaces_json).unwrap_or_default(); - let windows: Vec = serde_json::from_str(&windows_json).unwrap_or_default(); + 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()), + }; - workspaces.sort_by_key(|w| w.idx); + Ok((workspaces, windows)) +} - // Pre-compute terminal apps by checking /proc - let terminal_apps: HashMap = windows +fn output_status(workspaces: &[Workspace], windows: &[Window]) { + // Pre-compute terminal apps + let terminal_apps: HashMap = windows .iter() - .filter(|w| is_terminal(&w.app_id)) - .filter_map(|w| w.pid) - .map(|pid| (pid, detect_terminal_app(pid))) + .filter(|w| is_terminal(w.app_id.as_deref().unwrap_or(""))) + .filter_map(|w| w.pid.map(|pid| (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 = Vec::new(); let mut after_focused: Vec = Vec::new(); let mut focused_output = String::new(); @@ -68,10 +87,10 @@ fn main() { let mut chrome_idx = 0; let mut tooltip = String::new(); - for ws in &workspaces { + for ws in &sorted_workspaces { let mut ws_windows: Vec<&Window> = windows .iter() - .filter(|w| w.workspace_id == ws.id) + .filter(|w| w.workspace_id == Some(ws.id)) .collect(); ws_windows.sort_by_key(|w| w.id); @@ -88,7 +107,9 @@ fn main() { } else { let mut output = String::new(); for win in &ws_windows { - let mut color = get_color(&win.app_id, &win.title, win.pid, &terminal_apps); + 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); if color == "chrome" { const CHROME_COLORS: [&str; 4] = ["#ea4335", "#fbbc05", "#34a853", "#4285f4"]; @@ -102,7 +123,7 @@ fn main() { let bar = if win.is_focused { "█" - } else if !ws.is_focused && Some(win.id) == ws.active_window_id { + } else if !ws.is_focused && ws.active_window_id == Some(win.id) { "▌" } else { "|" @@ -182,7 +203,7 @@ fn get_all_descendants(pid: u32) -> std::io::Result> { Ok(descendants) } -fn get_color(app_id: &str, title: &str, pid: Option, terminal_apps: &HashMap) -> String { +fn get_color(app_id: &str, title: &str, pid: Option, terminal_apps: &HashMap) -> String { if is_terminal(app_id) { let title_lower = title.to_lowercase(); @@ -201,7 +222,8 @@ fn get_color(app_id: &str, title: &str, pid: Option, terminal_apps: &HashMa "codex" => "#56b6c2", "nvim" => "#98c379", _ => "#888888", - }.to_string(); + } + .to_string(); } } -- cgit v1.3 From cf5957fb63c8c847d811b724130ff26333b7da99 Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Fri, 19 Dec 2025 04:11:23 -0800 Subject: Add README with benchmarks --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..41124d9 --- /dev/null +++ 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 -- cgit v1.3 From aae742a3f77c7370ab17e6d8b099f1faefaf7e2d Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Sat, 20 Dec 2025 17:18:14 -0800 Subject: Fix window ordering and mark visible windows --- src/main.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7a8a1a6..fcdf1e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,7 +93,7 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { .filter(|w| w.workspace_id == Some(ws.id)) .collect(); - ws_windows.sort_by_key(|w| w.id); + ws_windows.sort_by_key(|win| window_sort_key(*win)); let win_count = ws_windows.len(); tooltip.push_str(&format!("ws{}: {} windows\\n", ws.idx, win_count)); @@ -129,7 +129,15 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { "|" }; - output.push_str(&format!("{}", color, bar)); + let strike = if ws.is_active { + " strikethrough=\"true\"" + } else { + "" + }; + output.push_str(&format!( + "{}", + color, strike, bar + )); } output }; @@ -268,3 +276,12 @@ fn dim_color(hex: &str) -> String { 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) + } +} -- cgit v1.3 From 8dc6dbb906c7cde13f8956a98a4c57cdb2268d7b Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Sat, 20 Dec 2025 17:19:28 -0800 Subject: Fix JSON output for strikethrough --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index fcdf1e2..1bbcba3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,7 +130,7 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { }; let strike = if ws.is_active { - " strikethrough=\"true\"" + " strikethrough='true'" } else { "" }; -- cgit v1.3 From fe31ae07808753a3b29b03e71aa768d37b9ad05e Mon Sep 17 00:00:00 2001 From: 1jehuang Date: Sat, 20 Dec 2025 18:35:26 -0800 Subject: Remove strikethrough rendering --- src/main.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1bbcba3..5d28f90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,15 +129,7 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { "|" }; - let strike = if ws.is_active { - " strikethrough='true'" - } else { - "" - }; - output.push_str(&format!( - "{}", - color, strike, bar - )); + output.push_str(&format!("{}", color, bar)); } output }; -- cgit v1.3 From bd1b99bd77bc8cde668d64e743fc15f890b9784c Mon Sep 17 00:00:00 2001 From: Jeremy Huang <94247773+1jehuang@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:10:11 -0700 Subject: Update README.md with image --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 41124d9..2d3fe42 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A fast, event-driven workspace indicator for [Waybar](https://github.com/Alexays/Waybar) and [niri](https://github.com/YaLTeR/niri). +image + ## Features - **Visual bars** instead of numbers - each window is a colored bar -- cgit v1.3 From cb4c26da4748d2fcf53ef7bd4335c48f94e91e7f Mon Sep 17 00:00:00 2001 From: Jeremy Huang <94247773+1jehuang@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:10:38 -0700 Subject: Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d3fe42..8226bfa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A fast, event-driven workspace indicator for [Waybar](https://github.com/Alexays/Waybar) and [niri](https://github.com/YaLTeR/niri). -image +image ## Features -- cgit v1.3