From eaeba8409267e0a14d8c991f082cdfcb7d3ce9a7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 7 Apr 2026 11:42:57 +0200 Subject: [PATCH] refactored cli, extracted menu to cli, removed deprecated --- src/bt_menu.rs | 135 ++++++++++++++ src/client.rs | 44 +++++ src/daemon.rs | 3 +- src/health.rs | 13 +- src/help.rs | 498 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 161 ++-------------- src/output.rs | 77 +++++++- 7 files changed, 773 insertions(+), 158 deletions(-) create mode 100644 src/bt_menu.rs create mode 100644 src/client.rs create mode 100644 src/help.rs diff --git a/src/bt_menu.rs b/src/bt_menu.rs new file mode 100644 index 0000000..91b2d7b --- /dev/null +++ b/src/bt_menu.rs @@ -0,0 +1,135 @@ +//! Bluetooth interactive menu (client-side). +//! +//! Runs entirely in the client process because it needs to spawn the user's +//! menu command (rofi/dmenu/wofi) — the daemon has no business opening GUI +//! windows. Communicates with the daemon via IPC to fetch device lists and +//! dispatch connect/disconnect/mode commands. + +/// Format strings used both when *building* menu items and when *parsing* +/// the user's selection back. Keeping them together prevents drift. +mod fmt { + /// Connected device with a plugin mode: `": Mode: []"`. + pub const MODE_INFIX: &str = ": Mode: "; + /// Disconnect action: `"Disconnect []"`. + pub const DISCONNECT_PREFIX: &str = "Disconnect "; + /// Visual separator before paired-but-not-connected devices. + pub const CONNECT_HEADER: &str = "--- Connect Device ---"; +} + +/// Extract a MAC address enclosed in `[…]` at the end of a string. +fn parse_mac_from_brackets(s: &str) -> Option<&str> { + let start = s.rfind('[')?; + let end = s.rfind(']')?; + if end > start + 1 { + Some(&s[start + 1..end]) + } else { + None + } +} + +/// Extract a MAC address enclosed in `(…)` at the end of a string. +fn parse_mac_from_parens(s: &str) -> Option<&str> { + let start = s.rfind('(')?; + let end = s.rfind(')')?; + if end > start + 1 { + Some(&s[start + 1..end]) + } else { + None + } +} + +/// Parse a mode selection line: `": Mode: []"`. +/// Returns `(mode, mac)`. +fn parse_mode_selection(s: &str) -> Option<(&str, &str)> { + let mac = parse_mac_from_brackets(s)?; + let mode_start = s.find(fmt::MODE_INFIX)?; + let mode_begin = mode_start + fmt::MODE_INFIX.len(); + let bracket_start = s.rfind('[')?; + if bracket_start > mode_begin { + let mode = s[mode_begin..bracket_start].trim_end(); + Some((mode, mac)) + } else { + None + } +} + +/// Run the interactive Bluetooth device menu. +/// +/// Fetches connected/paired devices from the daemon, presents them in the +/// user's configured menu command, and dispatches the selected action back +/// to the daemon. +pub fn run_bt_menu() { + let config = crate::config::load_config(None); + let mut items = Vec::new(); + + let mut connected: Vec<(String, String)> = Vec::new(); + let mut paired: Vec<(String, String)> = Vec::new(); + + // Fetch the device list from the daemon. + if let Ok(json_str) = crate::ipc::request_data("bt", &["menu_data"]) + && let Ok(val) = serde_json::from_str::(&json_str) + && let Some(text) = val.get("text").and_then(|t| t.as_str()) + { + for line in text.lines() { + if let Some(rest) = line.strip_prefix("CONNECTED:") + && let Some((alias, mac)) = rest.split_once('|') + { + connected.push((alias.to_string(), mac.to_string())); + } else if let Some(rest) = line.strip_prefix("PAIRED:") + && let Some((alias, mac)) = rest.split_once('|') + { + paired.push((alias.to_string(), mac.to_string())); + } + } + } + + // Build menu items for connected devices (modes + disconnect). + for (alias, mac) in &connected { + if let Ok(json_str) = crate::ipc::request_data("bt", &["get_modes", mac]) + && let Ok(val) = serde_json::from_str::(&json_str) + && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) + && !modes_str.is_empty() + { + for mode in modes_str.lines() { + items.push(format!("{}{}{} [{}]", alias, fmt::MODE_INFIX, mode, mac)); + } + } + items.push(format!("{}{} [{}]", fmt::DISCONNECT_PREFIX, alias, mac)); + } + + // Paired-but-not-connected devices go below a separator. + if !paired.is_empty() { + items.push(fmt::CONNECT_HEADER.to_string()); + for (alias, mac) in &paired { + items.push(format!("{} ({})", alias, mac)); + } + } + + if items.is_empty() { + tracing::info!("No Bluetooth options found."); + return; + } + + let Ok(selected) = crate::utils::show_menu("BT Menu: ", &items, &config.general.menu_command) + else { + return; + }; + + if let Some((mode, mac)) = parse_mode_selection(&selected) { + crate::output::print_waybar_response(crate::ipc::request_data( + "bt", + &["set_mode", mode, mac], + )); + } else if selected.starts_with(fmt::DISCONNECT_PREFIX) { + if let Some(mac) = parse_mac_from_brackets(&selected) { + crate::output::print_waybar_response(crate::ipc::request_data( + "bt", + &["disconnect", mac], + )); + } + } else if selected == fmt::CONNECT_HEADER { + // Section header — no action. + } else if let Some(mac) = parse_mac_from_parens(&selected) { + crate::output::print_waybar_response(crate::ipc::request_data("bt", &["connect", mac])); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..e0bff07 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,44 @@ +//! Client-side module command dispatch. +//! +//! Resolves CLI aliases (e.g. `mic` → audio source), delegates to +//! special-case handlers (BT menu), and falls through to the standard +//! IPC → daemon → Waybar JSON path for everything else. + +/// Resolve client-side module aliases that prepend implicit arguments. +/// +/// `vol` maps to the audio sink, `mic` maps to the audio source — both +/// dispatch to the `"vol"` module on the daemon with a `"sink"` or +/// `"source"` prefix argument. +fn resolve_alias(module: &str, args: &[String]) -> (String, Vec) { + match module { + "vol" => { + let mut a = vec!["sink".to_string()]; + a.extend(args.iter().cloned()); + ("vol".to_string(), a) + } + "mic" => { + let mut a = vec!["source".to_string()]; + a.extend(args.iter().cloned()); + ("vol".to_string(), a) + } + _ => (module.to_string(), args.to_vec()), + } +} + +/// Entry point for all `fluxo [args...]` invocations. +/// +/// Handles the BT menu special case client-side, resolves aliases, and +/// sends the request to the daemon via IPC. +pub fn run_module_command(module: &str, args: &[String]) { + // Bluetooth menu runs client-side because it spawns the user's menu + // command (rofi/dmenu/wofi) — the daemon must not open GUI windows. + #[cfg(feature = "mod-bt")] + if module == "bt" && args.first().map(|s| s.as_str()) == Some("menu") { + crate::bt_menu::run_bt_menu(); + return; + } + + let (actual_module, actual_args) = resolve_alias(module, args); + let args_ref: Vec<&str> = actual_args.iter().map(|s| s.as_str()).collect(); + crate::output::print_waybar_response(crate::ipc::request_data(&actual_module, &args_ref)); +} diff --git a/src/daemon.rs b/src/daemon.rs index 39e75dc..7b22b56 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -529,7 +529,8 @@ async fn handle_request( match result { Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()), Err(crate::error::FluxoError::Disabled(_)) => { - "{\"text\":\"\",\"tooltip\":\"Module disabled\",\"class\":\"disabled\"}".to_string() + serde_json::to_string(&crate::output::WaybarOutput::disabled()) + .unwrap_or_else(|_| "{}".to_string()) } Err(e) => crate::health::error_response(module_name, &e, cached_output), } diff --git a/src/health.rs b/src/health.rs index 6d3728c..48ee4f2 100644 --- a/src/health.rs +++ b/src/health.rs @@ -118,10 +118,8 @@ pub fn backoff_response(module_name: &str, cached: Option) -> Stri cached.class = Some(format!("{} warning", class).trim().to_string()); return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string()); } - format!( - "{{\"text\":\"\u{200B}Cooling down ({})\u{200B}\",\"class\":\"error\"}}", - module_name - ) + let zws = crate::output::ZERO_WIDTH_SPACE; + format!("{{\"text\":\"{zws}Cooling down ({module_name}){zws}\",\"class\":\"error\"}}") } /// Serialise a fallback response for a module that errored this request. @@ -141,11 +139,6 @@ pub fn error_response( let error_msg = e.to_string(); error!(module = module_name, error = %error_msg, "Module execution failed"); - let err_out = WaybarOutput { - text: "\u{200B}Error\u{200B}".to_string(), - tooltip: Some(error_msg), - class: Some("error".to_string()), - percentage: None, - }; + let err_out = WaybarOutput::error(&error_msg); serde_json::to_string(&err_out).unwrap_or_else(|_| "{}".to_string()) } diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..55e2aed --- /dev/null +++ b/src/help.rs @@ -0,0 +1,498 @@ +//! Human-readable help output for all available modules. +//! +//! `fluxo help` prints an overview of every module with its aliases, arguments, +//! and format tokens. `fluxo help ` shows the detailed page for a +//! single module. + +/// Module help descriptor used to build the help output. +struct ModuleHelp { + /// Primary display name. + name: &'static str, + /// CLI aliases that dispatch to this module. + aliases: &'static [&'static str], + /// Cargo feature gate required at compile time. + feature: &'static str, + /// One-line summary of what the module does. + summary: &'static str, + /// Argument synopsis in `[arg]` notation. + args_synopsis: &'static str, + /// Detailed argument descriptions. + args_detail: &'static [(&'static str, &'static str)], + /// Format tokens available in `config.toml`. + tokens: &'static [(&'static str, &'static str)], + /// Concrete usage examples. + examples: &'static [(&'static str, &'static str)], +} + +/// All module descriptors, ordered by category. +const MODULES: &[ModuleHelp] = &[ + // ── Hardware ───────────────────────────────────────────────────── + ModuleHelp { + name: "cpu", + aliases: &["cpu"], + feature: "mod-hardware", + summary: "CPU usage percentage and temperature.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("usage", "CPU usage as a percentage (0.0 - 100.0)"), + ("temp", "CPU temperature in degrees Celsius"), + ], + examples: &[("fluxo cpu", "Show current CPU usage and temperature")], + }, + ModuleHelp { + name: "memory", + aliases: &["mem", "memory"], + feature: "mod-hardware", + summary: "RAM usage in gigabytes with usage classification.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("used", "Used memory in GB"), + ("total", "Total memory in GB"), + ], + examples: &[("fluxo mem", "Show current RAM usage")], + }, + ModuleHelp { + name: "sys", + aliases: &["sys"], + feature: "mod-hardware", + summary: "Uptime, load averages, and process count.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("uptime", "Human-readable uptime (e.g. \"2d 5h\")"), + ("load1", "1-minute load average"), + ("load5", "5-minute load average"), + ("load15", "15-minute load average"), + ("procs", "Number of running processes"), + ], + examples: &[("fluxo sys", "Show system uptime and load")], + }, + ModuleHelp { + name: "gpu", + aliases: &["gpu"], + feature: "mod-hardware", + summary: "GPU usage, VRAM, and temperature (AMD/NVIDIA/Intel).", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("usage", "GPU utilisation percentage"), + ("vram_used", "Used VRAM in GB (AMD/NVIDIA)"), + ("vram_total", "Total VRAM in GB (AMD/NVIDIA)"), + ("temp", "GPU temperature in Celsius (AMD/NVIDIA)"), + ("freq", "GPU frequency in MHz (Intel)"), + ], + examples: &[("fluxo gpu", "Show GPU stats for the detected vendor")], + }, + ModuleHelp { + name: "disk", + aliases: &["disk"], + feature: "mod-hardware", + summary: "Filesystem usage for a given mount point.", + args_synopsis: "[mountpoint]", + args_detail: &[( + "mountpoint", + "Path to the mount point to display (default: \"/\")", + )], + tokens: &[ + ("mount", "The mount point path"), + ("used", "Used space in GB"), + ("total", "Total space in GB"), + ], + examples: &[ + ("fluxo disk", "Show usage of the root filesystem (/)"), + ("fluxo disk /home", "Show usage of /home"), + ], + }, + ModuleHelp { + name: "pool", + aliases: &["pool", "btrfs"], + feature: "mod-hardware", + summary: "Aggregated Btrfs pool usage across all btrfs mounts.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("used", "Total used space in GB across all btrfs mounts"), + ("total", "Total capacity in GB across all btrfs mounts"), + ], + examples: &[ + ("fluxo pool", "Show combined Btrfs pool usage"), + ("fluxo btrfs", "Same as above (alias)"), + ], + }, + ModuleHelp { + name: "power", + aliases: &["power"], + feature: "mod-hardware", + summary: "Battery percentage and charge state from sysfs.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("percentage", "Battery level (0 - 100)"), + ("icon", "State icon (varies by charge level and AC status)"), + ], + examples: &[("fluxo power", "Show battery status")], + }, + ModuleHelp { + name: "game", + aliases: &["game"], + feature: "mod-hardware", + summary: "Gamemode indicator (Hyprland animation state).", + args_synopsis: "", + args_detail: &[], + tokens: &[], + examples: &[("fluxo game", "Show whether gamemode is active")], + }, + // ── Network ────────────────────────────────────────────────────── + ModuleHelp { + name: "network", + aliases: &["net", "network"], + feature: "mod-network", + summary: "Primary network interface, IP, and transfer rates.", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("interface", "Active interface name (e.g. \"wlan0\")"), + ("ip", "IPv4 address of the active interface"), + ("rx", "Receive rate in MB/s"), + ("tx", "Transmit rate in MB/s"), + ], + examples: &[("fluxo net", "Show network status and throughput")], + }, + // ── Audio ──────────────────────────────────────────────────────── + ModuleHelp { + name: "vol (sink)", + aliases: &["vol"], + feature: "mod-audio", + summary: "PulseAudio/PipeWire output (sink) volume and controls.", + args_synopsis: "[show|up|down|mute|cycle] [step]", + args_detail: &[ + ( + "show", + "Display current sink volume and mute state (default)", + ), + ("up", "Increase volume by percent (default: 5)"), + ("down", "Decrease volume by percent (default: 5)"), + ("mute", "Toggle mute on the default sink"), + ("cycle", "Switch to the next available output device"), + ("step", "Volume change increment in percent (default: 5)"), + ], + tokens: &[ + ("name", "Device description (truncated to 20 chars)"), + ("icon", "Volume-level icon (changes with volume/mute)"), + ("volume", "Current volume percentage (0 - 150)"), + ], + examples: &[ + ("fluxo vol", "Show current sink volume"), + ("fluxo vol up", "Increase volume by 5%"), + ("fluxo vol up 10", "Increase volume by 10%"), + ("fluxo vol down 2", "Decrease volume by 2%"), + ("fluxo vol mute", "Toggle sink mute"), + ("fluxo vol cycle", "Switch to next output device"), + ], + }, + ModuleHelp { + name: "mic (source)", + aliases: &["mic"], + feature: "mod-audio", + summary: "PulseAudio/PipeWire input (source/microphone) controls.", + args_synopsis: "[show|up|down|mute|cycle] [step]", + args_detail: &[ + ( + "show", + "Display current source volume and mute state (default)", + ), + ("up", "Increase mic volume by percent (default: 5)"), + ("down", "Decrease mic volume by percent (default: 5)"), + ("mute", "Toggle mute on the default source"), + ("cycle", "Switch to the next available input device"), + ("step", "Volume change increment in percent (default: 5)"), + ], + tokens: &[ + ("name", "Device description (truncated to 20 chars)"), + ("icon", "Microphone icon (changes with mute state)"), + ("volume", "Current volume percentage (0 - 150)"), + ], + examples: &[ + ("fluxo mic", "Show current microphone volume"), + ("fluxo mic mute", "Toggle microphone mute"), + ("fluxo mic up 10", "Increase mic volume by 10%"), + ], + }, + // ── Bluetooth ──────────────────────────────────────────────────── + ModuleHelp { + name: "bluetooth", + aliases: &["bt", "bluetooth"], + feature: "mod-bt", + summary: "Bluetooth device status, connection management, and plugin modes.", + args_synopsis: "[show|connect|disconnect|cycle|menu|get_modes|set_mode|cycle_mode] [args...]", + args_detail: &[ + ("show", "Display the active device's status (default)"), + ( + "connect ", + "Connect to the device with the given MAC address", + ), + ( + "disconnect [mac]", + "Disconnect the active device, or a specific MAC", + ), + ( + "cycle", + "Cycle through connected devices (multi-device setups)", + ), + ( + "menu", + "Open an interactive device picker (client-side, uses menu_command)", + ), + ( + "get_modes [mac]", + "List available plugin modes (e.g. ANC modes for Pixel Buds)", + ), + ( + "set_mode [mac]", + "Set a plugin mode on the active or specified device", + ), + ("cycle_mode [mac]", "Advance to the next plugin mode"), + ], + tokens: &[ + ("alias", "Device display name (e.g. \"Pixel Buds Pro\")"), + ("mac", "Device MAC address"), + ("left", "Left earbud battery (plugin, e.g. \"85%\")"), + ("right", "Right earbud battery (plugin, e.g. \"90%\")"), + ( + "anc", + "ANC mode label (plugin, e.g. \"ANC\", \"Aware\", \"Off\")", + ), + ], + examples: &[ + ("fluxo bt", "Show the active BT device"), + ( + "fluxo bt connect AA:BB:CC:DD:EE:FF", + "Connect to a specific device", + ), + ("fluxo bt disconnect", "Disconnect the active device"), + ("fluxo bt menu", "Open the interactive BT device menu"), + ("fluxo bt cycle_mode", "Toggle ANC mode on Pixel Buds"), + ("fluxo bt set_mode aware", "Set ANC to aware mode"), + ], + }, + // ── D-Bus ──────────────────────────────────────────────────────── + ModuleHelp { + name: "mpris", + aliases: &["mpris"], + feature: "mod-dbus", + summary: "MPRIS media player status (artist, title, playback state).", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("artist", "Current track artist"), + ("title", "Current track title"), + ("album", "Current track album"), + ("status_icon", "Playback icon (play/pause/stop glyph)"), + ], + examples: &[("fluxo mpris", "Show current media player status")], + }, + ModuleHelp { + name: "backlight", + aliases: &["backlight"], + feature: "mod-dbus", + summary: "Screen brightness percentage (inotify-driven).", + args_synopsis: "", + args_detail: &[], + tokens: &[ + ("percentage", "Current brightness level (0 - 100)"), + ("icon", "Brightness bucket icon"), + ], + examples: &[("fluxo backlight", "Show current screen brightness")], + }, + ModuleHelp { + name: "keyboard", + aliases: &["kbd", "keyboard"], + feature: "mod-dbus", + summary: "Active keyboard layout (Hyprland event-driven).", + args_synopsis: "", + args_detail: &[], + tokens: &[( + "layout", + "Active keyboard layout name (e.g. \"English (US)\")", + )], + examples: &[("fluxo kbd", "Show the current keyboard layout")], + }, + ModuleHelp { + name: "dnd", + aliases: &["dnd"], + feature: "mod-dbus", + summary: "Do-Not-Disturb toggle (SwayNC signal-driven / Dunst polling).", + args_synopsis: "[show|toggle]", + args_detail: &[ + ("show", "Display the current DND state (default)"), + ("toggle", "Toggle DND on/off via the notification daemon"), + ], + tokens: &[], + examples: &[ + ("fluxo dnd", "Show current DND state"), + ("fluxo dnd toggle", "Toggle Do-Not-Disturb"), + ], + }, +]; + +/// Print help for all modules or a single module by name. +pub fn print_help(module: Option<&str>) { + if let Some(name) = module { + let found = MODULES.iter().find(|m| { + m.aliases.iter().any(|a| a.eq_ignore_ascii_case(name)) + || m.name.eq_ignore_ascii_case(name) + }); + + match found { + Some(m) => print_module_detail(m), + None => { + eprintln!("Unknown module: \"{}\"\n", name); + eprintln!("Run `fluxo help` to see all available modules."); + std::process::exit(1); + } + } + } else { + print_overview(); + } +} + +fn print_overview() { + println!("\x1b[1;36mfluxo\x1b[0m — high-performance daemon/client for Waybar custom modules\n"); + + println!("\x1b[1mUSAGE:\x1b[0m"); + println!(" fluxo daemon [--config ] Start the background daemon"); + println!(" fluxo reload Hot-reload the daemon config"); + println!(" fluxo [args...] Query or control a module"); + println!(" fluxo help [module] Show this help or module details\n"); + + println!("\x1b[1mCONFIGURATION:\x1b[0m"); + println!(" Config file: $XDG_CONFIG_HOME/fluxo/config.toml"); + println!(" Format tokens in config strings use {{token}} syntax."); + println!(" Run `fluxo help ` to see available tokens.\n"); + + let categories: &[(&str, &[&str])] = &[ + ( + "Hardware", + &[ + "cpu", "memory", "sys", "gpu", "disk", "pool", "power", "game", + ], + ), + ("Network", &["network"]), + ("Audio", &["vol (sink)", "mic (source)"]), + ("Bluetooth", &["bluetooth"]), + ("D-Bus", &["mpris", "backlight", "keyboard", "dnd"]), + ]; + + println!("\x1b[1mMODULES:\x1b[0m\n"); + + for (category, names) in categories { + println!( + " \x1b[1;33m{}\x1b[0m ({})", + category, + feature_for_category(category) + ); + for module_name in *names { + if let Some(m) = MODULES.iter().find(|m| m.name == *module_name) { + let aliases = m.aliases.join(", "); + println!(" \x1b[1;32m{:<18}\x1b[0m {}", aliases, m.summary,); + if !m.args_synopsis.is_empty() { + println!(" {:<18} args: {}", "", m.args_synopsis,); + } + } + } + println!(); + } + + println!("\x1b[1mEXAMPLES:\x1b[0m\n"); + println!(" fluxo daemon Start the daemon"); + println!(" fluxo cpu Show CPU usage and temperature"); + println!(" fluxo vol up 10 Increase volume by 10%"); + println!(" fluxo bt menu Open Bluetooth device picker"); + println!(" fluxo dnd toggle Toggle Do-Not-Disturb"); + println!(" fluxo help vol Show detailed help for the volume module"); + println!(); + println!("For detailed module info: \x1b[1mfluxo help \x1b[0m"); +} + +fn print_module_detail(m: &ModuleHelp) { + println!("\x1b[1;36mfluxo {}\x1b[0m — {}\n", m.name, m.summary); + + // Aliases + if m.aliases.len() > 1 + || m.aliases.first() != Some(&m.name.split_whitespace().next().unwrap_or(m.name)) + { + println!("\x1b[1mALIASES:\x1b[0m {}", m.aliases.join(", ")); + println!(); + } + + // Feature gate + println!("\x1b[1mFEATURE:\x1b[0m {}", m.feature); + println!(); + + // Usage + println!("\x1b[1mUSAGE:\x1b[0m"); + let primary = m.aliases.first().unwrap_or(&m.name); + if m.args_synopsis.is_empty() { + println!(" fluxo {}", primary); + } else { + println!(" fluxo {} {}", primary, m.args_synopsis); + } + println!(); + + // Arguments + if !m.args_detail.is_empty() { + println!("\x1b[1mARGUMENTS:\x1b[0m\n"); + let max_name = m + .args_detail + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(0); + for (name, desc) in m.args_detail { + println!( + " \x1b[32m{: &'static str { + match category { + "Hardware" => "mod-hardware", + "Network" => "mod-network", + "Audio" => "mod-audio", + "Bluetooth" => "mod-bt", + "D-Bus" => "mod-dbus", + _ => "default", + } +} diff --git a/src/main.rs b/src/main.rs index 9022113..3c25897 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,10 +14,14 @@ #[macro_use] mod macros; +#[cfg(feature = "mod-bt")] +mod bt_menu; +mod client; mod config; mod daemon; mod error; mod health; +mod help; mod ipc; mod modules; mod output; @@ -35,6 +39,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*}; #[derive(Parser)] #[command(name = "fluxo")] #[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)] +#[command(disable_help_subcommand = true)] struct Cli { #[command(subcommand)] command: Option, @@ -57,6 +62,11 @@ enum Commands { }, /// Reload the daemon configuration Reload, + /// Show detailed help for all modules or a specific module + Help { + /// Optional module name to show detailed help for + module: Option, + }, } fn main() { @@ -88,157 +98,16 @@ fn main() { process::exit(1); } }, + Commands::Help { module } => { + help::print_help(module.as_deref()); + } } return; } if let Some(module) = &cli.module { - // Bluetooth menu is handled client-side: it needs access to the user's - // menu command (rofi/dmenu/wofi) which the daemon has no business spawning. - #[cfg(feature = "mod-bt")] - if module == "bt" && cli.args.first().map(|s| s.as_str()) == Some("menu") { - let config = config::load_config(None); - let mut items = Vec::new(); - - // Ask the daemon for the device list; tuples are (alias, mac). - let mut connected: Vec<(String, String)> = Vec::new(); - let mut paired: Vec<(String, String)> = Vec::new(); - - if let Ok(json_str) = ipc::request_data("bt", &["menu_data"]) - && let Ok(val) = serde_json::from_str::(&json_str) - && let Some(text) = val.get("text").and_then(|t| t.as_str()) - { - for line in text.lines() { - if let Some(rest) = line.strip_prefix("CONNECTED:") - && let Some((alias, mac)) = rest.split_once('|') - { - connected.push((alias.to_string(), mac.to_string())); - } else if let Some(rest) = line.strip_prefix("PAIRED:") - && let Some((alias, mac)) = rest.split_once('|') - { - paired.push((alias.to_string(), mac.to_string())); - } - } - } - - for (alias, mac) in &connected { - if let Ok(json_str) = ipc::request_data("bt", &["get_modes", mac]) - && let Ok(val) = serde_json::from_str::(&json_str) - && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) - && !modes_str.is_empty() - { - for mode in modes_str.lines() { - items.push(format!("{}: Mode: {} [{}]", alias, mode, mac)); - } - } - items.push(format!("Disconnect {} [{}]", alias, mac)); - } - - if !paired.is_empty() { - items.push("--- Connect Device ---".to_string()); - for (alias, mac) in &paired { - items.push(format!("{} ({})", alias, mac)); - } - } - - if !items.is_empty() { - if let Ok(selected) = - utils::show_menu("BT Menu: ", &items, &config.general.menu_command) - { - if selected.contains(": Mode: ") { - // Parse ": Mode: []". - if let Some(bracket_start) = selected.rfind('[') - && let Some(bracket_end) = selected.rfind(']') - { - let mac = &selected[bracket_start + 1..bracket_end]; - if let Some(mode_start) = selected.find(": Mode: ") { - let mode = - &selected[mode_start + ": Mode: ".len()..bracket_start - 1]; - handle_ipc_response(ipc::request_data( - "bt", - &["set_mode", mode, mac], - )); - } - } - } else if selected.starts_with("Disconnect ") { - // Parse "Disconnect []". - if let Some(bracket_start) = selected.rfind('[') - && let Some(bracket_end) = selected.rfind(']') - { - let mac = &selected[bracket_start + 1..bracket_end]; - handle_ipc_response(ipc::request_data("bt", &["disconnect", mac])); - } - } else if selected == "--- Connect Device ---" { - // section header - } else if let Some(mac_start) = selected.rfind('(') - && let Some(mac_end) = selected.rfind(')') - { - let mac = &selected[mac_start + 1..mac_end]; - handle_ipc_response(ipc::request_data("bt", &["connect", mac])); - } - } - } else { - info!("No Bluetooth options found."); - } - return; - } - - // `vol` and `mic` both dispatch to the audio module; we just prepend - // the "sink" / "source" argument so the server picks the right device. - let (actual_module, actual_args) = if module == "vol" { - let mut new_args = vec!["sink".to_string()]; - new_args.extend(cli.args.clone()); - ("vol".to_string(), new_args) - } else if module == "mic" { - let mut new_args = vec!["source".to_string()]; - new_args.extend(cli.args.clone()); - ("vol".to_string(), new_args) - } else { - (module.clone(), cli.args.clone()) - }; - - let args_ref: Vec<&str> = actual_args.iter().map(|s| s.as_str()).collect(); - handle_ipc_response(ipc::request_data(&actual_module, &args_ref)); + client::run_module_command(module, &cli.args); } else { - println!("Please specify a module or command. See --help."); - process::exit(1); - } -} - -/// Post-process the daemon's response for direct output to Waybar. -/// -/// Normal spaces are replaced with figure-spaces (U+2007) so Waybar's -/// proportional font does not jitter between updates, and the text is wrapped -/// in zero-width spaces (U+200B) as a cosmetic padding trick. Markup strings -/// (containing `<`) pass through untouched. On IPC failure an `error` output -/// is emitted and the client exits non-zero. -fn handle_ipc_response(response: anyhow::Result) { - match response { - Ok(json_str) => match serde_json::from_str::(&json_str) { - Ok(mut val) => { - if let Some(text) = val.get_mut("text").and_then(|t| t.as_str()) { - let processed_text = if text.contains('<') { - text.to_string() - } else { - text.replace(' ', "\u{2007}") - }; - - let fixed_text = format!("\u{200B}{}\u{200B}", processed_text); - val["text"] = serde_json::Value::String(fixed_text); - } - println!("{}", serde_json::to_string(&val).unwrap()); - } - Err(_) => println!("{}", json_str), - }, - Err(e) => { - let err_out = output::WaybarOutput { - text: format!("\u{200B}Daemon offline ({})\u{200B}", e), - tooltip: Some(e.to_string()), - class: Some("error".to_string()), - percentage: None, - }; - println!("{}", serde_json::to_string(&err_out).unwrap()); - process::exit(1); - } + help::print_help(None); } } diff --git a/src/output.rs b/src/output.rs index 680a67b..47b5fab 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,7 +1,16 @@ -//! JSON payload returned to Waybar custom modules. +//! JSON payload returned to Waybar custom modules, plus client-side +//! output formatting utilities. use serde::{Deserialize, Serialize}; +/// Waybar renders in a proportional font — replacing normal spaces with +/// figure-spaces (U+2007) keeps column widths stable across updates. +pub const FIGURE_SPACE: char = '\u{2007}'; + +/// Zero-width space used as cosmetic padding around module text so Waybar +/// doesn't clip leading/trailing glyphs. +pub const ZERO_WIDTH_SPACE: char = '\u{200B}'; + /// A Waybar custom module return value. /// /// Serialises to the schema Waybar's `return-type: json` expects — the @@ -21,6 +30,72 @@ pub struct WaybarOutput { pub percentage: Option, } +impl WaybarOutput { + /// A blank output for disabled modules. + pub fn disabled() -> Self { + Self { + text: String::new(), + tooltip: Some("Module disabled".to_string()), + class: Some("disabled".to_string()), + percentage: None, + } + } + + /// A user-visible error with tooltip detail. + pub fn error(message: &str) -> Self { + Self { + text: format!("{}Error{}", ZERO_WIDTH_SPACE, ZERO_WIDTH_SPACE), + tooltip: Some(message.to_string()), + class: Some("error".to_string()), + percentage: None, + } + } +} + +/// Apply Waybar font-stabilisation to a text string. +/// +/// Replaces normal spaces with figure-spaces (unless the string contains +/// markup), and wraps in zero-width spaces for cosmetic padding. +pub fn stabilize_text(text: &str) -> String { + let processed = if text.contains('<') { + text.to_string() + } else { + text.replace(' ', &FIGURE_SPACE.to_string()) + }; + format!("{}{}{}", ZERO_WIDTH_SPACE, processed, ZERO_WIDTH_SPACE) +} + +/// Process an IPC response and print Waybar-compatible JSON to stdout. +/// +/// On IPC failure, prints a "Daemon offline" error output and exits +/// non-zero so Waybar surfaces the problem visually. +pub fn print_waybar_response(response: anyhow::Result) { + match response { + Ok(json_str) => match serde_json::from_str::(&json_str) { + Ok(mut val) => { + if let Some(text) = val.get("text").and_then(|t| t.as_str()) { + val["text"] = serde_json::Value::String(stabilize_text(text)); + } + println!("{}", serde_json::to_string(&val).unwrap()); + } + Err(_) => println!("{}", json_str), + }, + Err(e) => { + let err_out = WaybarOutput { + text: format!( + "{}Daemon offline ({}){}", + ZERO_WIDTH_SPACE, e, ZERO_WIDTH_SPACE + ), + tooltip: Some(e.to_string()), + class: Some("error".to_string()), + percentage: None, + }; + println!("{}", serde_json::to_string(&err_out).unwrap()); + std::process::exit(1); + } + } +} + #[cfg(test)] mod tests { use super::*;