refactored cli, extracted menu to cli, removed deprecated
Release / Build and Release (push) Has been cancelled

This commit is contained in:
2026-04-07 11:42:57 +02:00
parent ffdb689ef9
commit eaeba84092
7 changed files with 773 additions and 158 deletions
+135
View File
@@ -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: `"<alias>: Mode: <mode> [<mac>]"`.
pub const MODE_INFIX: &str = ": Mode: ";
/// Disconnect action: `"Disconnect <alias> [<mac>]"`.
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: `"<alias>: Mode: <mode> [<mac>]"`.
/// 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::<serde_json::Value>(&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::<serde_json::Value>(&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]));
}
}
+44
View File
@@ -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<String>) {
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 <module> [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));
}
+2 -1
View File
@@ -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),
}
+3 -10
View File
@@ -118,10 +118,8 @@ pub fn backoff_response(module_name: &str, cached: Option<WaybarOutput>) -> 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())
}
+498
View File
@@ -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 <module>` 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 <step> percent (default: 5)"),
("down", "Decrease volume by <step> 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 <step> percent (default: 5)"),
("down", "Decrease mic volume by <step> 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 <mac>",
"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 <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 <path>] Start the background daemon");
println!(" fluxo reload Hot-reload the daemon config");
println!(" fluxo <module> [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 <module>` 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 <module>\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{:<width$}\x1b[0m {}",
name,
desc,
width = max_name
);
}
println!();
}
// Format tokens
if !m.tokens.is_empty() {
println!("\x1b[1mFORMAT TOKENS:\x1b[0m (for use in config.toml format strings)\n");
let max_token = m.tokens.iter().map(|(t, _)| t.len()).max().unwrap_or(0);
for (token, desc) in m.tokens {
println!(
" \x1b[33m{{{:<width$}}}\x1b[0m {}",
token,
desc,
width = max_token
);
}
println!();
}
// Examples
if !m.examples.is_empty() {
println!("\x1b[1mEXAMPLES:\x1b[0m\n");
for (cmd, desc) in m.examples {
println!(" \x1b[1m$\x1b[0m {:<34} # {}", cmd, desc);
}
println!();
}
}
fn feature_for_category(category: &str) -> &'static str {
match category {
"Hardware" => "mod-hardware",
"Network" => "mod-network",
"Audio" => "mod-audio",
"Bluetooth" => "mod-bt",
"D-Bus" => "mod-dbus",
_ => "default",
}
}
+15 -146
View File
@@ -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<Commands>,
@@ -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<String>,
},
}
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::<serde_json::Value>(&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::<serde_json::Value>(&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 "<alias>: Mode: <mode> [<MAC>]".
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 <alias> [<MAC>]".
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<String>) {
match response {
Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&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);
}
}
+76 -1
View File
@@ -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<u8>,
}
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<String>) {
match response {
Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&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::*;