From 3a89c9fc3ce62fdbbc37e228d9389908cd7d6ace Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 13 Mar 2026 19:02:44 +0100 Subject: [PATCH] added menu functionality (default fuzzel) --- README.md | 101 ++++++++++++++++++++++--------------------- example.config.toml | 32 ++++++++++++++ src/config.rs | 76 ++++++++++++++++++++++++++++++++ src/main.rs | 46 +++++++++++++++++--- src/modules/audio.rs | 21 +++++---- src/modules/bt.rs | 16 +++---- src/modules/buds.rs | 36 +++++++-------- src/modules/game.rs | 12 ++--- src/utils.rs | 32 ++++++++++++++ 9 files changed, 269 insertions(+), 103 deletions(-) create mode 100644 src/utils.rs diff --git a/README.md b/README.md index b12119f..c1eb117 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,59 @@ # fluxo-rs -fluxo-rs is a high-performance system metrics daemon and client designed specifically for waybar. it replaces standard shell scripts with a compiled rust binary that collects data via a background polling loop and serves it over a unix domain socket (/tmp/fluxo.sock). +fluxo-rs is a high-performance system metrics daemon and client designed specifically for waybar. It replaces standard shell scripts with a compiled rust binary that collects data via a background polling loop and serves it over a unix domain socket (`/tmp/fluxo.sock`). -## description +## Description -the project follows a client-server architecture: -- daemon: handles heavy lifting (polling cpu, memory, network, gpu) and stores state in memory. -- client: a thin cli wrapper that connects to the daemon's socket to retrieve formatted json for waybar. +The project follows a client-server architecture: +- **Daemon**: Handles heavy lifting (polling cpu, memory, network, gpu) and stores state in memory. +- **Client**: A thin cli wrapper that connects to the daemon's socket to retrieve formatted json for waybar. -this approach eliminates process spawning overhead and temporary file locking, resulting in near-zero cpu usage for custom modules. +This approach eliminates process spawning overhead and temporary file locking, resulting in near-zero cpu usage for custom modules. -## features +## Features -- ultra-lightweight: background polling is highly optimized (e.g., O(1) process counting). -- jitter-free: uses zero-width sentinels and figure spaces to prevent waybar from trimming padding. -- configurable: customizable output formats via toml config. -- live reload: configuration can be reloaded without restarting the daemon. -- multi-vendor gpu: native support for intel (igpu), amd, and nvidia. +- **Ultra-lightweight**: Background polling is highly optimized (e.g., O(1) process counting). +- **Jitter-free**: Uses zero-width sentinels and figure spaces to prevent waybar from trimming padding. +- **Configurable**: Fully customizable output formats via toml config. +- **Interactive Menus**: Integrated support for selecting items (like Bluetooth devices) via external menus (e.g., Rofi, Wofi). +- **Live Reload**: Configuration can be reloaded without restarting the daemon. +- **Multi-vendor GPU**: Native support for intel (igpu), amd, and nvidia. -## modules +## Modules -| command | description | tokens | +| Command | Description | Tokens | | :--- | :--- | :--- | -| `net` | network speed (rx/tx) | `{interface}`, `{ip}`, `{rx}`, `{tx}` | -| `cpu` | cpu usage and temp | `{usage}`, `{temp}` | -| `mem` | memory usage | `{used}`, `{total}` | -| `gpu` | gpu utilization | `{usage}`, `{vram_used}`, `{vram_total}`, `{temp}` | -| `sys` | system load and uptime | `{uptime}`, `{load1}`, `{load5}`, `{load15}` | -| `disk` | disk usage (default: /) | `{mount}`, `{used}`, `{total}` | -| `pool` | aggregate storage (btrfs) | `{used}`, `{total}` | -| `vol` | audio output volume | `{percentage}`, `{icon}` | -| `mic` | audio input volume | `{percentage}`, `{icon}` | -| `bt` | bluetooth status | device name and battery | -| `buds` | pixel buds pro control | left/right battery and anc state | -| `power` | battery and ac status | `{percentage}`, `{icon}` | -| `game` | hyprland gamemode status | active/inactive icon | +| `net` | Network speed (rx/tx) | `{interface}`, `{ip}`, `{rx}`, `{tx}` | +| `cpu` | CPU usage and temp | `{usage}`, `{temp}` | +| `mem` | Memory usage | `{used}`, `{total}` | +| `gpu` | GPU utilization | `{usage}`, `{vram_used}`, `{vram_total}`, `{temp}` | +| `sys` | System load and uptime | `{uptime}`, `{load1}`, `{load5}`, `{load15}` | +| `disk` | Disk usage (default: /) | `{mount}`, `{used}`, `{total}` | +| `pool` | Aggregate storage (btrfs) | `{used}`, `{total}` | +| `vol` | Audio output volume | `{name}`, `{volume}`, `{icon}` | +| `mic` | Audio input volume | `{name}`, `{volume}`, `{icon}` | +| `bt` | Bluetooth status | `{alias}` | +| `buds` | Pixel Buds Pro control | `{left}`, `{right}`, `{anc}` | +| `power` | Battery and AC status | `{percentage}`, `{icon}` | +| `game` | Hyprland gamemode status | active/inactive icon strings | -## setup +## Setup -1. build the project: +1. Build the project: ```bash cd fluxo-rs cargo build --release ``` -2. start the daemon: +2. Start the daemon: ```bash ./target/release/fluxo-rs daemon & ``` -3. configuration: - create `~/.config/fluxo/config.toml` (see `example.config.toml` for all options). +3. Configuration: + Create `~/.config/fluxo/config.toml` (see `example.config.toml` for all default options). -4. waybar configuration (`config.jsonc`): +4. Waybar configuration (`config.jsonc`): ```json "custom/cpu": { "exec": "~/path/to/fluxo-rs cpu", @@ -60,29 +61,31 @@ this approach eliminates process spawning overhead and temporary file locking, r } ``` -## development +## Development -### architecture -- `src/main.rs`: entry point, cli parsing, and client-side formatting logic. -- `src/daemon.rs`:uds listener, configuration management, and polling orchestration. -- `src/ipc.rs`: unix domain socket communication logic. -- `src/modules/`: individual metric implementations. -- `src/state.rs`: shared thread-safe data structures. +### Architecture +- `src/main.rs`: Entry point, CLI parsing, interactive GUI spawns (menus), and client-side formatting logic. +- `src/daemon.rs`: UDS listener, configuration management, and polling orchestration. +- `src/ipc.rs`: Unix domain socket communication logic. +- `src/utils.rs`: Generic GUI utilities (like the menu spawner). +- `src/modules/`: Individual metric implementations. +- `src/state.rs`: Shared thread-safe data structures. -### adding a module -1. add the required fields to `src/state.rs`. -2. implement the `WaybarModule` trait in a new file in `src/modules/`. -3. add polling logic to `src/modules/hardware.rs` or `src/daemon.rs`. -4. register the new subcommand in `src/main.rs` and the router in `src/daemon.rs`. +### Adding a Module +1. Add the required config block to `src/config.rs`. +2. Add the required state fields to `src/state.rs`. +3. Implement the `WaybarModule` trait in a new file in `src/modules/`. +4. Add polling logic to `src/modules/hardware.rs` or `src/daemon.rs`. +5. Register the new subcommand in `src/main.rs` and the router in `src/daemon.rs`. -### configuration reload -the daemon can reload its configuration live: +### Configuration Reload +The daemon can reload its configuration live: ```bash fluxo-rs reload ``` -### logs -run the daemon with debug logs for troubleshooting: +### Logs +Run the daemon with debug logs for troubleshooting: ```bash RUST_LOG=debug fluxo-rs daemon ``` diff --git a/example.config.toml b/example.config.toml index de4a4df..c920dd9 100644 --- a/example.config.toml +++ b/example.config.toml @@ -1,6 +1,11 @@ # fluxo-rs example configuration # place this at ~/.config/fluxo/config.toml +[general] +# command used for interactive menus (e.g., bluetooth device selection) +# tokens: {prompt} +menu_command = "fuzzel --dmenu --prompt '{prompt}'" + # network module (net) # tokens: {interface}, {ip}, {rx}, {tx}, {rx:>5.2}, {tx:>5.2} [network] @@ -42,3 +47,30 @@ format = "{used:>4.0}G / {total:>4.0}G" # tokens: {percentage}, {icon}, {percentage:>3} [power] format = "{percentage:>3}% {icon}" + +# audio module (vol / mic) +# tokens: {name}, {volume}, {icon}, {volume:>3} +[audio] +format_sink_unmuted = "{name} {volume:>3}% {icon}" +format_sink_muted = "{name} {icon}" +format_source_unmuted = "{name} {volume:>3}% {icon}" +format_source_muted = "{name} {icon}" + +# bluetooth module (bt) +# tokens: {alias} +[bt] +format_connected = "{alias} 󰂰" +format_disconnected = "󰂯" +format_disabled = "󰂲 Off" + +# pixel buds module (buds) +# tokens: {left}, {right}, {anc} +[buds] +mac = "B4:23:A2:09:D3:53" +format = "{left} | {right} | {anc}" +format_disconnected = "" + +# gamemode module (game) +[game] +format_active = "󰊖" +format_inactive = "" diff --git a/src/config.rs b/src/config.rs index 1088a85..e33f01a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; #[derive(Deserialize, Default)] pub struct Config { + #[serde(default)] + pub general: GeneralConfig, #[serde(default)] pub network: NetworkConfig, #[serde(default)] @@ -22,6 +24,25 @@ pub struct Config { pub power: PowerConfig, #[serde(default)] pub buds: BudsConfig, + #[serde(default)] + pub audio: AudioConfig, + #[serde(default)] + pub bt: BtConfig, + #[serde(default)] + pub game: GameConfig, +} + +#[derive(Deserialize)] +pub struct GeneralConfig { + pub menu_command: String, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + menu_command: "fuzzel --dmenu --prompt '{prompt}'".to_string(), + } + } } #[derive(Deserialize)] @@ -135,12 +156,67 @@ impl Default for PowerConfig { #[derive(Deserialize)] pub struct BudsConfig { pub mac: String, + pub format: String, + pub format_disconnected: String, } impl Default for BudsConfig { fn default() -> Self { Self { mac: "B4:23:A2:09:D3:53".to_string(), + format: "{left} | {right} | {anc}".to_string(), + format_disconnected: "".to_string(), + } + } +} + +#[derive(Deserialize)] +pub struct AudioConfig { + pub format_sink_unmuted: String, + pub format_sink_muted: String, + pub format_source_unmuted: String, + pub format_source_muted: String, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + format_sink_unmuted: "{name} {volume:>3}% {icon}".to_string(), + format_sink_muted: "{name} {icon}".to_string(), + format_source_unmuted: "{name} {volume:>3}% {icon}".to_string(), + format_source_muted: "{name} {icon}".to_string(), + } + } +} + +#[derive(Deserialize)] +pub struct BtConfig { + pub format_connected: String, + pub format_disconnected: String, + pub format_disabled: String, +} + +impl Default for BtConfig { + fn default() -> Self { + Self { + format_connected: "{alias} 󰂰".to_string(), + format_disconnected: "󰂯".to_string(), + format_disabled: "󰂲 Off".to_string(), + } + } +} + +#[derive(Deserialize)] +pub struct GameConfig { + pub format_active: String, + pub format_inactive: String, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + format_active: "󰊖".to_string(), + format_inactive: "".to_string(), } } } diff --git a/src/main.rs b/src/main.rs index b2cd9ed..14ab820 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod ipc; mod modules; mod output; mod state; +mod utils; use clap::{Parser, Subcommand}; use std::process; @@ -116,7 +117,44 @@ fn main() { } Commands::Gpu => handle_ipc_response(ipc::request_data("gpu", &[])), Commands::Sys => handle_ipc_response(ipc::request_data("sys", &[])), - Commands::Bt { action } => handle_ipc_response(ipc::request_data("bt", &[action.clone()])), + Commands::Bt { action } => { + if action == "menu" { + // Client-side execution of the menu + let config = config::load_config(); + + let devices_out = std::process::Command::new("bluetoothctl") + .args(["devices"]) + .output() + .expect("Failed to run bluetoothctl"); + let stdout = String::from_utf8_lossy(&devices_out.stdout); + + let mut items = Vec::new(); + for line in stdout.lines() { + if line.starts_with("Device ") { + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + if parts.len() == 3 { + // Format: "Alias (MAC)" + items.push(format!("{} ({})", parts[2], parts[1])); + } + } + } + + if !items.is_empty() { + if let Ok(selected) = utils::show_menu("Connect BT:", &items, &config.general.menu_command) { + if let Some(mac_start) = selected.rfind('(') { + if let Some(mac_end) = selected.rfind(')') { + let mac = &selected[mac_start + 1..mac_end]; + let _ = std::process::Command::new("bluetoothctl") + .args(["connect", mac]) + .status(); + } + } + } + } + return; // Exit client after menu + } + handle_ipc_response(ipc::request_data("bt", &[action.clone()])); + } Commands::Buds { action } => handle_ipc_response(ipc::request_data("buds", &[action.clone()])), Commands::Power => handle_ipc_response(ipc::request_data("power", &[])), Commands::Game => handle_ipc_response(ipc::request_data("game", &[])), @@ -129,13 +167,7 @@ fn handle_ipc_response(response: anyhow::Result) { match serde_json::from_str::(&json_str) { Ok(mut val) => { if let Some(text) = val.get_mut("text").and_then(|t| t.as_str()) { - // Intelligent formatting: - // Only replace spaces with Figure Spaces if they are part of padding (surrounded by spaces or at start). - // If the text contains '<', we assume it's Pango markup and perform a safer replacement - // that avoids breaking tags. let processed_text = if text.contains('<') { - // If it's markup, we only wrap in sentinels. - // Individual modules with markup shouldn't really have variable-width spaces inside tags. text.to_string() } else { text.replace(' ', "\u{2007}") diff --git a/src/modules/audio.rs b/src/modules/audio.rs index fb0ea6c..86c2f6d 100644 --- a/src/modules/audio.rs +++ b/src/modules/audio.rs @@ -8,7 +8,7 @@ use std::process::Command; pub struct AudioModule; impl WaybarModule for AudioModule { - fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result { + fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { let target_type = args.first().unwrap_or(&"sink"); let action = args.get(1).unwrap_or(&"show"); @@ -23,23 +23,21 @@ impl WaybarModule for AudioModule { }); } "show" | _ => { - self.get_status(target_type) + self.get_status(config, target_type) } } } } impl AudioModule { - fn get_status(&self, target_type: &str) -> Result { + fn get_status(&self, config: &Config, target_type: &str) -> Result { let target = if target_type == "sink" { "@DEFAULT_AUDIO_SINK@" } else { "@DEFAULT_AUDIO_SOURCE@" }; - // Get volume and mute status via wpctl (faster than pactl for this) let output = Command::new("wpctl") .args(["get-volume", target]) .output()?; let stdout = String::from_utf8_lossy(&output.stdout); - // Output format: "Volume: 0.50" or "Volume: 0.50 [MUTED]" let parts: Vec<&str> = stdout.trim().split_whitespace().collect(); if parts.len() < 2 { return Err(anyhow!("Could not parse wpctl output: {}", stdout)); @@ -59,7 +57,8 @@ impl AudioModule { let (text, class) = if muted { let icon = if target_type == "sink" { "" } else { "" }; - (format!("{} {}", name, icon), "muted") + let format_str = if target_type == "sink" { &config.audio.format_sink_muted } else { &config.audio.format_source_muted }; + (format_str.replace("{name}", &name).replace("{icon}", icon), "muted") } else { let icon = if target_type == "sink" { if display_vol <= 30 { "" } @@ -68,7 +67,13 @@ impl AudioModule { } else { "" }; - (format!("{} {}% {}", name, display_vol, icon), "unmuted") + let format_str = if target_type == "sink" { &config.audio.format_sink_unmuted } else { &config.audio.format_source_unmuted }; + let t = format_str + .replace("{name}", &name) + .replace("{icon}", icon) + .replace("{volume:>3}", &format!("{:>3}", display_vol)) + .replace("{volume}", &format!("{}", display_vol)); + (t, "unmuted") }; Ok(WaybarOutput { @@ -80,7 +85,6 @@ impl AudioModule { } fn get_description(&self, target_type: &str) -> Result { - // Get the default device name let info_output = Command::new("pactl").arg("info").output()?; let info_stdout = String::from_utf8_lossy(&info_output.stdout); let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" }; @@ -91,7 +95,6 @@ impl AudioModule { .map(|s| s.trim()) .ok_or_else(|| anyhow!("Default {} not found", target_type))?; - // Get the description of that device let list_cmd = if target_type == "sink" { "sinks" } else { "sources" }; let list_output = Command::new("pactl").args(["list", list_cmd]).output()?; let list_stdout = String::from_utf8_lossy(&list_output.stdout); diff --git a/src/modules/bt.rs b/src/modules/bt.rs index 6f763e2..9e66bb5 100644 --- a/src/modules/bt.rs +++ b/src/modules/bt.rs @@ -8,7 +8,7 @@ use std::process::Command; pub struct BtModule; impl WaybarModule for BtModule { - fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result { + fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { let action = args.first().unwrap_or(&"show"); if *action == "disconnect" { @@ -23,12 +23,11 @@ impl WaybarModule for BtModule { }); } - // Check if bluetooth is powered on if let Ok(output) = Command::new("bluetoothctl").arg("show").output() { let stdout = String::from_utf8_lossy(&output.stdout); if stdout.contains("Powered: no") { return Ok(WaybarOutput { - text: "󰂲 Off".to_string(), + text: config.bt.format_disabled.clone(), tooltip: Some("Bluetooth Disabled".to_string()), class: Some("disabled".to_string()), percentage: None, @@ -64,16 +63,17 @@ impl WaybarModule for BtModule { battery.map(|b| format!("{}%", b)).unwrap_or_else(|| "N/A".to_string()) ); + let text = config.bt.format_connected.replace("{alias}", &alias); + Ok(WaybarOutput { - text: format!("{} 󰂰", alias), + text, tooltip: Some(tooltip), class: Some("connected".to_string()), percentage: battery, }) } else { - // No device connected but Bluetooth is on Ok(WaybarOutput { - text: "󰂯".to_string(), + text: config.bt.format_disconnected.clone(), tooltip: Some("Bluetooth On (Disconnected)".to_string()), class: Some("disconnected".to_string()), percentage: None, @@ -83,7 +83,6 @@ impl WaybarModule for BtModule { } fn find_audio_device() -> Option { - // 1. Try to check if current default sink is a bluetooth device if let Ok(output) = Command::new("pactl").arg("get-default-sink").output() { let sink = String::from_utf8_lossy(&output.stdout).trim().to_string(); if sink.starts_with("bluez_output.") { @@ -94,7 +93,6 @@ fn find_audio_device() -> Option { } } - // 2. Fallback: Search bluetoothctl for connected devices with Audio Sink UUID if let Ok(output) = Command::new("bluetoothctl").args(["devices", "Connected"]).output() { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { @@ -104,7 +102,7 @@ fn find_audio_device() -> Option { let mac = parts[1]; if let Ok(info) = Command::new("bluetoothctl").args(["info", mac]).output() { let info_str = String::from_utf8_lossy(&info.stdout); - if info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") { // Audio Sink UUID + if info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") { return Some(mac.to_string()); } } diff --git a/src/modules/buds.rs b/src/modules/buds.rs index 6d2fff7..4466448 100644 --- a/src/modules/buds.rs +++ b/src/modules/buds.rs @@ -20,56 +20,48 @@ impl WaybarModule for BudsModule { let next_mode = match current_mode.as_str() { "active" => "aware", "aware" => "off", - _ => "active", // default or off goes to active + _ => "active", }; Command::new("pbpctrl").args(["set", "anc", next_mode]).status()?; return Ok(WaybarOutput { text: String::new(), - tooltip: None, - class: None, - percentage: None, + tooltip: None, class: None, percentage: None, }); } "connect" => { Command::new("bluetoothctl").args(["connect", mac]).status()?; return Ok(WaybarOutput { text: String::new(), - tooltip: None, - class: None, - percentage: None, + tooltip: None, class: None, percentage: None, }); } "disconnect" => { Command::new("bluetoothctl").args(["disconnect", mac]).status()?; return Ok(WaybarOutput { text: String::new(), - tooltip: None, - class: None, - percentage: None, + tooltip: None, class: None, percentage: None, }); } "show" | _ => {} } - // Check if connected let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?; let bt_str = String::from_utf8_lossy(&bt_info.stdout); if !bt_str.contains("Connected: yes") { return Ok(WaybarOutput { - text: "".to_string(), + text: config.buds.format_disconnected.clone(), tooltip: Some("Pixel Buds Pro 2 not connected".to_string()), class: Some("disconnected".to_string()), percentage: None, }); } - // Get battery output let bat_cmd = Command::new("pbpctrl").args(["show", "battery"]).output(); if bat_cmd.is_err() || !bat_cmd.as_ref().unwrap().status.success() { return Ok(WaybarOutput { - text: "".to_string(), + text: config.buds.format_disconnected.clone(), tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()), class: Some("disconnected".to_string()), percentage: None, @@ -92,16 +84,13 @@ impl WaybarModule for BudsModule { if left_bud == "unknown" && right_bud == "unknown" { return Ok(WaybarOutput { text: "{}".to_string(), - tooltip: None, - class: None, - percentage: None, + tooltip: None, class: None, percentage: None, }); } - let left_display = if left_bud == "unknown" { "L: ---".to_string() } else { format!("L: {}", left_bud) }; - let right_display = if right_bud == "unknown" { "R: ---".to_string() } else { format!("R: {}", right_bud) }; + let left_display = if left_bud == "unknown" { "---".to_string() } else { format!("{}%", left_bud) }; + let right_display = if right_bud == "unknown" { "---".to_string() } else { format!("{}%", right_bud) }; - // Get ANC info let anc_cmd = Command::new("pbpctrl").args(["get", "anc"]).output()?; let current_mode = String::from_utf8_lossy(&anc_cmd.stdout).trim().to_string(); @@ -112,8 +101,13 @@ impl WaybarModule for BudsModule { _ => ("?", "anc-unknown"), }; + let text = config.buds.format + .replace("{left}", &left_display) + .replace("{right}", &right_display) + .replace("{anc}", anc_icon); + Ok(WaybarOutput { - text: format!("{} | {} | {}", left_display, right_display, anc_icon), + text, tooltip: Some("Pixel Buds Pro 2".to_string()), class: Some(class.to_string()), percentage: None, diff --git a/src/modules/game.rs b/src/modules/game.rs index b627dde..034ea31 100644 --- a/src/modules/game.rs +++ b/src/modules/game.rs @@ -8,19 +8,15 @@ use std::process::Command; pub struct GameModule; impl WaybarModule for GameModule { - fn run(&self, _config: &Config, _state: &SharedState, _args: &[&str]) -> Result { + fn run(&self, config: &Config, _state: &SharedState, _args: &[&str]) -> Result { let output = Command::new("hyprctl") .args(["getoption", "animations:enabled", "-j"]) .output(); - let mut is_gamemode = false; // default to deactivated + let mut is_gamemode = false; if let Ok(out) = output { let stdout = String::from_utf8_lossy(&out.stdout); - - // The JSON from hyprctl looks like {"int": 0, "float": 0.0, ...} - // If int is 0, animations are disabled (Gamemode active) - // If int is 1, animations are enabled (Gamemode deactivated) if stdout.contains("\"int\": 0") { is_gamemode = true; } @@ -28,14 +24,14 @@ impl WaybarModule for GameModule { if is_gamemode { Ok(WaybarOutput { - text: "󰊖".to_string(), + text: config.game.format_active.clone(), tooltip: Some("Gamemode activated".to_string()), class: Some("active".to_string()), percentage: None, }) } else { Ok(WaybarOutput { - text: "".to_string(), + text: config.game.format_inactive.clone(), tooltip: Some("Gamemode deactivated".to_string()), class: None, percentage: None, diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..ab5cf7a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,32 @@ +use anyhow::{Context, Result}; +use std::io::Write; +use std::process::{Command, Stdio}; + +pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result { + let cmd_str = menu_cmd.replace("{prompt}", prompt); + let mut child = Command::new("sh") + .arg("-c") + .arg(&cmd_str) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("Failed to spawn menu command")?; + + if let Some(mut stdin) = child.stdin.take() { + let input = items.join("\n"); + stdin.write_all(input.as_bytes()).context("Failed to write to menu stdin")?; + } + + let output = child.wait_with_output().context("Failed to wait on menu")?; + + if !output.status.success() { + return Err(anyhow::anyhow!("Menu cancelled or failed")); + } + + let selected = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if selected.is_empty() { + return Err(anyhow::anyhow!("No item selected")); + } + + Ok(selected) +}