main.rs (11650B)
1 use niri_ipc::socket::Socket; 2 use niri_ipc::{Event, Request, Response, Window, Workspace}; 3 use std::collections::HashMap; 4 use std::fs; 5 6 fn main() { 7 loop { 8 if let Err(e) = run_daemon() { 9 eprintln!("Daemon error: {}, reconnecting in 1s...", e); 10 std::thread::sleep(std::time::Duration::from_secs(1)); 11 } 12 } 13 } 14 15 fn run_daemon() -> Result<(), Box<dyn std::error::Error>> { 16 // Get initial state and output 17 let (workspaces, windows) = fetch_state()?; 18 output_status(&workspaces, &windows); 19 20 // Subscribe to event stream 21 let mut socket = Socket::connect()?; 22 let reply = socket.send(Request::EventStream)?; 23 24 match reply { 25 Ok(Response::Handled) => {} 26 Ok(other) => return Err(format!("Unexpected response: {:?}", other).into()), 27 Err(msg) => return Err(msg.into()), 28 } 29 30 let mut read_event = socket.read_events(); 31 32 loop { 33 let event = read_event()?; 34 35 // Only update on relevant events 36 match event { 37 Event::WorkspacesChanged { .. } 38 | Event::WorkspaceActivated { .. } 39 | Event::WindowsChanged { .. } 40 | Event::WindowOpenedOrChanged { .. } 41 | Event::WindowClosed { .. } 42 | Event::WindowFocusChanged { .. } => { 43 if let Ok((ws, win)) = fetch_state() { 44 output_status(&ws, &win); 45 } 46 } 47 _ => {} 48 } 49 } 50 } 51 52 fn fetch_state() -> Result<(Vec<Workspace>, Vec<Window>), Box<dyn std::error::Error>> { 53 let mut socket = Socket::connect()?; 54 55 let workspaces = match socket.send(Request::Workspaces)? { 56 Ok(Response::Workspaces(ws)) => ws, 57 Ok(other) => return Err(format!("Unexpected: {:?}", other).into()), 58 Err(msg) => return Err(msg.into()), 59 }; 60 61 let mut socket = Socket::connect()?; 62 let windows = match socket.send(Request::Windows)? { 63 Ok(Response::Windows(ws)) => ws, 64 Ok(other) => return Err(format!("Unexpected: {:?}", other).into()), 65 Err(msg) => return Err(msg.into()), 66 }; 67 68 Ok((workspaces, windows)) 69 } 70 71 fn output_status(workspaces: &[Workspace], windows: &[Window]) { 72 // Pre-compute terminal apps. 73 // Avoid per-pid process detection for foot/footclient when multiple windows 74 // share the same pid, to prevent false positives across windows. 75 let mut pid_counts: HashMap<i32, usize> = HashMap::new(); 76 for w in windows { 77 if let Some(pid) = w.pid { 78 *pid_counts.entry(pid).or_insert(0) += 1; 79 } 80 } 81 82 let terminal_apps: HashMap<i32, &str> = windows 83 .iter() 84 .filter(|w| is_terminal(w.app_id.as_deref().unwrap_or(""))) 85 .filter_map(|w| { 86 let pid = w.pid?; 87 let app_id = w.app_id.as_deref().unwrap_or(""); 88 if (app_id == "foot" || app_id == "footclient" || app_id == "kitty") 89 && pid_counts.get(&pid).copied().unwrap_or(0) > 1 90 { 91 return None; 92 } 93 Some((pid, detect_terminal_app(pid as u32))) 94 }) 95 .filter(|(_, app)| !app.is_empty()) 96 .collect(); 97 98 let mut sorted_workspaces: Vec<&Workspace> = workspaces.iter().collect(); 99 sorted_workspaces.sort_by_key(|w| w.idx); 100 101 let mut before_focused: Vec<String> = Vec::new(); 102 let mut after_focused: Vec<String> = Vec::new(); 103 let mut focused_output = String::new(); 104 let mut current_section = "before"; 105 let mut tooltip = String::new(); 106 107 for ws in &sorted_workspaces { 108 let mut ws_windows: Vec<&Window> = windows 109 .iter() 110 .filter(|w| w.workspace_id == Some(ws.id)) 111 .collect(); 112 113 ws_windows.sort_by_key(|win| window_sort_key(*win)); 114 115 let win_count = ws_windows.len(); 116 117 // Get workspace name if it's not default/empty 118 let ws_name = ws.name.as_deref().unwrap_or(""); 119 let has_custom_name = !ws_name.is_empty(); 120 121 // Only add to tooltip if workspace has windows 122 if win_count > 0 { 123 if has_custom_name { 124 tooltip.push_str(&format!("{}: {} windows\\n", ws_name, win_count)); 125 } else { 126 tooltip.push_str(&format!("ws{}: {} windows\\n", ws.idx, win_count)); 127 } 128 } 129 130 // Skip empty unfocused workspaces entirely 131 if win_count == 0 && !ws.is_focused { 132 continue; 133 } 134 135 let ws_output = if win_count == 0 { 136 // Empty but focused - just show a dot, no name 137 // "<span color='#888888'>·</span>".to_string() 138 format!("{} <span color='#cccccc'>.</span>", ws.idx) 139 } else { 140 let mut output = String::new(); 141 142 // Always show the workspace number on the left 143 if ws.is_focused { 144 output.push_str(&format!("<span color='#cccccc'>{}</span> ", ws.idx)); 145 } 146 147 // Show workspace name only when focused 148 if has_custom_name && ws.is_focused { 149 output.push_str(&format!("<span color='#b8a9e9'>{}</span> ", ws_name)); 150 } 151 152 for win in &ws_windows { 153 let app_id = win.app_id.as_deref().unwrap_or(""); 154 let title = win.title.as_deref().unwrap_or(""); 155 let mut color = get_color(app_id, title, win.pid, &terminal_apps); 156 let is_tmux = is_tmux_title(title); 157 158 if !ws.is_focused { 159 color = dim_color(&color); 160 } 161 162 let mut bar = if win.is_focused { 163 "<span color='#f6764f'>█</span>" 164 } else if !ws.is_focused && ws.active_window_id == Some(win.id) { 165 "▌" 166 } else { 167 "|" 168 }; 169 170 // Use a broken bar for tmux without changing focus semantics. 171 if is_tmux && bar == "|" { 172 bar = "¦"; 173 } 174 175 // output.push_str(&format!("<span color='{}'>{}</span>", color, bar)); 176 output.push_str(&format!("<span color='{}'>{}</span>", color, bar)); 177 } 178 output 179 }; 180 181 if ws.is_focused { 182 focused_output = ws_output; 183 current_section = "after"; 184 } else if current_section == "before" { 185 before_focused.push(ws_output); 186 } else { 187 after_focused.push(ws_output); 188 } 189 } 190 191 let mut output = String::new(); 192 for ws in &before_focused { 193 output.push_str(ws); 194 output.push_str(" "); 195 } 196 output.push_str(&focused_output); 197 for ws in &after_focused { 198 output.push_str(" "); 199 output.push_str(ws); 200 } 201 202 if tooltip.ends_with("\\n") { 203 tooltip.truncate(tooltip.len() - 2); 204 } 205 206 println!(r#"{{"text": "{}", "tooltip": "{}"}}"#, output, tooltip); 207 } 208 209 fn is_terminal(app_id: &str) -> bool { 210 app_id == "Alacritty" 211 || app_id == "kitty" 212 || app_id == "com.mitchellh.ghostty" 213 || app_id == "foot" 214 || app_id == "footclient" 215 } 216 217 fn is_tmux_title(title: &str) -> bool { 218 title.to_lowercase().contains("tmux") 219 } 220 221 fn is_claude_title(title: &str) -> bool { 222 let lower = title.to_lowercase(); 223 lower.contains("claude") || title.contains("✳") 224 } 225 226 fn detect_terminal_app(pid: u32) -> &'static str { 227 let mut found_claude = false; 228 let mut found_codex = false; 229 let mut found_jcode = false; 230 let mut found_nvim = false; 231 232 if let Ok(children) = get_all_descendants(pid) { 233 for child_pid in children { 234 if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", child_pid)) { 235 let cmdline = cmdline.to_lowercase(); 236 if cmdline.contains("jcode") { 237 found_jcode = true; 238 } 239 if cmdline.contains("claude") { 240 found_claude = true; 241 } 242 if cmdline.contains("codex") { 243 found_codex = true; 244 } 245 if cmdline.contains("nvim") || cmdline.contains("vim") { 246 found_nvim = true; 247 } 248 } 249 } 250 } 251 252 if found_jcode { 253 "jcode" 254 } else if found_claude { 255 "claude" 256 } else if found_codex { 257 "codex" 258 } else if found_nvim { 259 "nvim" 260 } else { 261 "" 262 } 263 } 264 265 fn get_all_descendants(pid: u32) -> std::io::Result<Vec<u32>> { 266 let mut descendants = Vec::new(); 267 let mut to_check = vec![pid]; 268 269 while let Some(current) = to_check.pop() { 270 let children_path = format!("/proc/{}/task/{}/children", current, current); 271 if let Ok(children_str) = fs::read_to_string(&children_path) { 272 for child in children_str.split_whitespace() { 273 if let Ok(child_pid) = child.parse::<u32>() { 274 descendants.push(child_pid); 275 to_check.push(child_pid); 276 } 277 } 278 } 279 } 280 281 Ok(descendants) 282 } 283 284 fn get_color( 285 app_id: &str, 286 title: &str, 287 pid: Option<i32>, 288 terminal_apps: &HashMap<i32, &str>, 289 ) -> String { 290 if is_terminal(app_id) { 291 let title_lower = title.to_lowercase(); 292 293 if title_lower.contains("jcode") { 294 return "#999999".to_string(); 295 } else if is_claude_title(title) { 296 return "#f5a623".to_string(); 297 } else if title_lower.contains("codex") { 298 return "#56b6c2".to_string(); 299 } else if title_lower.contains("nvim") || title_lower.contains("vim") { 300 return "#98c379".to_string(); 301 } 302 303 if let Some(pid) = pid { 304 if let Some(&app) = terminal_apps.get(&pid) { 305 return match app { 306 "jcode" => "#999999", 307 "claude" => "#f5a623", 308 "codex" => "#56b6c2", 309 "nvim" => "#98c379", 310 _ => "#888888", 311 } 312 .to_string(); 313 } 314 } 315 316 return "#888888".to_string(); 317 } 318 319 if app_id.contains("nvim") { 320 return "#98c379".to_string(); 321 } 322 if app_id == "chromium" || app_id.contains("chromium") { 323 return "#ea4335".to_string(); 324 } 325 if app_id == "zen" { 326 return "#ff7139".to_string(); 327 } 328 if app_id == "firefox" { 329 return "#ffdd46".to_string(); 330 } 331 if app_id == "vesktop" || app_id == "discord" { 332 return "#c678dd".to_string(); 333 } 334 if app_id == "spotify" || app_id.contains("spotify") { 335 return "#1DB954".to_string(); 336 } 337 if app_id.contains("todoist") { 338 return "#e06c75".to_string(); 339 } 340 if app_id.contains("mail.google") { 341 return "#e5c07b".to_string(); 342 } 343 344 "#666666".to_string() 345 } 346 347 fn dim_color(hex: &str) -> String { 348 let hex = hex.trim_start_matches('#'); 349 if hex.len() != 6 { 350 return format!("#{}", hex); 351 } 352 353 let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); 354 let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); 355 let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); 356 357 let r = ((r as u32 * 6 / 10) + 30).min(255) as u8; 358 let g = ((g as u32 * 6 / 10) + 30).min(255) as u8; 359 let b = ((b as u32 * 6 / 10) + 30).min(255) as u8; 360 361 format!("#{:02x}{:02x}{:02x}", r, g, b) 362 } 363 364 fn window_sort_key(win: &Window) -> (u8, u64, u64, u64) { 365 if let Some((col, row)) = win.layout.pos_in_scrolling_layout { 366 (0, col as u64, row as u64, win.id) 367 } else { 368 // Floating/unknown layout windows go after tiled windows. 369 (1, 0, 0, win.id) 370 } 371 }