refactored cli, extracted menu to cli, removed deprecated
Release / Build and Release (push) Has been cancelled
Release / Build and Release (push) Has been cancelled
This commit is contained in:
+135
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user