diff options
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | src/main.rs | 132 |
2 files changed, 121 insertions, 33 deletions
@@ -6,12 +6,16 @@ A fast, event-driven workspace indicator for [Waybar](https://github.com/Alexays ## 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 (red), 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 @@ -38,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 index 5d28f90..7eb4a5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,11 +69,29 @@ fn fetch_state() -> Result<(Vec<Workspace>, Vec<Window>), Box<dyn std::error::Er } fn output_status(workspaces: &[Workspace], windows: &[Window]) { - // Pre-compute terminal apps + // 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| w.pid.map(|pid| (pid, detect_terminal_app(pid as u32)))) + .filter_map(|w| { + let pid = w.pid?; + let app_id = w.app_id.as_deref().unwrap_or(""); + if (app_id == "foot" || app_id == "footclient" || app_id == "kitty") + && 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(); @@ -84,7 +102,6 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { 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 { @@ -96,32 +113,47 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { 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)); - let ws_output = if win_count == 0 { - if ws.is_focused { - "<span color='#888888'>█</span>".to_string() + // 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(); + + // 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 { - "<span color='#444444'>·</span>".to_string() + 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 - just show a dot, no name + "<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); - - if color == "chrome" { - const CHROME_COLORS: [&str; 4] = ["#ea4335", "#fbbc05", "#34a853", "#4285f4"]; - color = CHROME_COLORS[chrome_idx % 4].to_string(); - chrome_idx += 1; - } + let is_tmux = is_tmux_title(title); if !ws.is_focused { color = dim_color(&color); } - let bar = if win.is_focused { + let mut bar = if win.is_focused { "█" } else if !ws.is_focused && ws.active_window_id == Some(win.id) { "▌" @@ -129,6 +161,11 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { "|" }; + // 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 @@ -163,25 +200,59 @@ fn output_status(workspaces: &[Workspace], windows: &[Window]) { } fn is_terminal(app_id: &str) -> bool { - app_id == "Alacritty" || app_id == "kitty" || app_id == "com.mitchellh.ghostty" + 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_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") { - return "claude"; - } else if cmdline.contains("codex") { - return "codex"; - } else if cmdline.contains("nvim") || cmdline.contains("vim") { - return "nvim"; + found_claude = true; + } + if cmdline.contains("codex") { + found_codex = true; + } + if cmdline.contains("nvim") || cmdline.contains("vim") { + found_nvim = true; } } } } - "" + + if found_jcode { + "jcode" + } else 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>> { @@ -203,11 +274,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 title_lower.contains("claude") { + 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(); @@ -218,6 +296,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", @@ -234,7 +313,7 @@ fn get_color(app_id: &str, title: &str, pid: Option<i32>, terminal_apps: &HashMa return "#98c379".to_string(); } if app_id == "google-chrome" || app_id.contains("chrome") { - return "chrome".to_string(); + return "#ea4335".to_string(); } if app_id == "firefox" { return "#ff7139".to_string(); @@ -242,6 +321,9 @@ fn get_color(app_id: &str, title: &str, pid: Option<i32>, terminal_apps: &HashMa 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(); } |
