niri-workspaces-rs

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

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 }