diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 107 | ||||
| -rw-r--r-- | Cargo.toml | 13 | ||||
| -rw-r--r-- | src/main.rs | 248 |
4 files changed, 369 insertions, 0 deletions
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<i64>, +} + +#[derive(Deserialize)] +struct Window { + id: i64, + workspace_id: i64, + app_id: String, + title: String, + is_focused: bool, + pid: Option<u32>, +} + +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<Workspace> = serde_json::from_str(&workspaces_json).unwrap_or_default(); + let windows: Vec<Window> = 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<u32, &str> = 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<String> = Vec::new(); + 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 &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 { + "<span color='#888888'>โ</span>".to_string() + } else { + "<span color='#444444'>ยท</span>".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!("<span color='{}'>{}</span>", 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<Vec<u32>> { + 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::<u32>() { + descendants.push(child_pid); + to_check.push(child_pid); + } + } + } + } + + Ok(descendants) +} + +fn get_color(app_id: &str, title: &str, pid: Option<u32>, terminal_apps: &HashMap<u32, &str>) -> 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) +} |
