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 {
|
match result {
|
||||||
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()),
|
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()),
|
||||||
Err(crate::error::FluxoError::Disabled(_)) => {
|
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),
|
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());
|
cached.class = Some(format!("{} warning", class).trim().to_string());
|
||||||
return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string());
|
return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string());
|
||||||
}
|
}
|
||||||
format!(
|
let zws = crate::output::ZERO_WIDTH_SPACE;
|
||||||
"{{\"text\":\"\u{200B}Cooling down ({})\u{200B}\",\"class\":\"error\"}}",
|
format!("{{\"text\":\"{zws}Cooling down ({module_name}){zws}\",\"class\":\"error\"}}")
|
||||||
module_name
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialise a fallback response for a module that errored this request.
|
/// 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();
|
let error_msg = e.to_string();
|
||||||
error!(module = module_name, error = %error_msg, "Module execution failed");
|
error!(module = module_name, error = %error_msg, "Module execution failed");
|
||||||
let err_out = WaybarOutput {
|
let err_out = WaybarOutput::error(&error_msg);
|
||||||
text: "\u{200B}Error\u{200B}".to_string(),
|
|
||||||
tooltip: Some(error_msg),
|
|
||||||
class: Some("error".to_string()),
|
|
||||||
percentage: None,
|
|
||||||
};
|
|
||||||
serde_json::to_string(&err_out).unwrap_or_else(|_| "{}".to_string())
|
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]
|
#[macro_use]
|
||||||
mod macros;
|
mod macros;
|
||||||
|
#[cfg(feature = "mod-bt")]
|
||||||
|
mod bt_menu;
|
||||||
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
mod error;
|
mod error;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod help;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod modules;
|
mod modules;
|
||||||
mod output;
|
mod output;
|
||||||
@@ -35,6 +39,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
|||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "fluxo")]
|
#[command(name = "fluxo")]
|
||||||
#[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)]
|
#[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)]
|
||||||
|
#[command(disable_help_subcommand = true)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
@@ -57,6 +62,11 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Reload the daemon configuration
|
/// Reload the daemon configuration
|
||||||
Reload,
|
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() {
|
fn main() {
|
||||||
@@ -88,157 +98,16 @@ fn main() {
|
|||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Commands::Help { module } => {
|
||||||
|
help::print_help(module.as_deref());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(module) = &cli.module {
|
if let Some(module) = &cli.module {
|
||||||
// Bluetooth menu is handled client-side: it needs access to the user's
|
client::run_module_command(module, &cli.args);
|
||||||
// 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));
|
|
||||||
} else {
|
} else {
|
||||||
println!("Please specify a module or command. See --help.");
|
help::print_help(None);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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};
|
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.
|
/// A Waybar custom module return value.
|
||||||
///
|
///
|
||||||
/// Serialises to the schema Waybar's `return-type: json` expects — the
|
/// Serialises to the schema Waybar's `return-type: json` expects — the
|
||||||
@@ -21,6 +30,72 @@ pub struct WaybarOutput {
|
|||||||
pub percentage: Option<u8>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user