added menu functionality (default fuzzel)

This commit is contained in:
2026-03-13 19:02:44 +01:00
parent 2ed522d9e5
commit 3a89c9fc3c
9 changed files with 269 additions and 103 deletions

View File

@@ -4,6 +4,8 @@ use std::path::PathBuf;
#[derive(Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
@@ -22,6 +24,25 @@ pub struct Config {
pub power: PowerConfig,
#[serde(default)]
pub buds: BudsConfig,
#[serde(default)]
pub audio: AudioConfig,
#[serde(default)]
pub bt: BtConfig,
#[serde(default)]
pub game: GameConfig,
}
#[derive(Deserialize)]
pub struct GeneralConfig {
pub menu_command: String,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
menu_command: "fuzzel --dmenu --prompt '{prompt}'".to_string(),
}
}
}
#[derive(Deserialize)]
@@ -135,12 +156,67 @@ impl Default for PowerConfig {
#[derive(Deserialize)]
pub struct BudsConfig {
pub mac: String,
pub format: String,
pub format_disconnected: String,
}
impl Default for BudsConfig {
fn default() -> Self {
Self {
mac: "B4:23:A2:09:D3:53".to_string(),
format: "{left} | {right} | {anc}".to_string(),
format_disconnected: "<span size='large'></span>".to_string(),
}
}
}
#[derive(Deserialize)]
pub struct AudioConfig {
pub format_sink_unmuted: String,
pub format_sink_muted: String,
pub format_source_unmuted: String,
pub format_source_muted: String,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
format_sink_unmuted: "{name} {volume:>3}% {icon}".to_string(),
format_sink_muted: "{name} {icon}".to_string(),
format_source_unmuted: "{name} {volume:>3}% {icon}".to_string(),
format_source_muted: "{name} {icon}".to_string(),
}
}
}
#[derive(Deserialize)]
pub struct BtConfig {
pub format_connected: String,
pub format_disconnected: String,
pub format_disabled: String,
}
impl Default for BtConfig {
fn default() -> Self {
Self {
format_connected: "{alias} 󰂰".to_string(),
format_disconnected: "󰂯".to_string(),
format_disabled: "󰂲 Off".to_string(),
}
}
}
#[derive(Deserialize)]
pub struct GameConfig {
pub format_active: String,
pub format_inactive: String,
}
impl Default for GameConfig {
fn default() -> Self {
Self {
format_active: "<span size='large'>󰊖</span>".to_string(),
format_inactive: "<span size='large'></span>".to_string(),
}
}
}

View File

@@ -4,6 +4,7 @@ mod ipc;
mod modules;
mod output;
mod state;
mod utils;
use clap::{Parser, Subcommand};
use std::process;
@@ -116,7 +117,44 @@ fn main() {
}
Commands::Gpu => handle_ipc_response(ipc::request_data("gpu", &[])),
Commands::Sys => handle_ipc_response(ipc::request_data("sys", &[])),
Commands::Bt { action } => handle_ipc_response(ipc::request_data("bt", &[action.clone()])),
Commands::Bt { action } => {
if action == "menu" {
// Client-side execution of the menu
let config = config::load_config();
let devices_out = std::process::Command::new("bluetoothctl")
.args(["devices"])
.output()
.expect("Failed to run bluetoothctl");
let stdout = String::from_utf8_lossy(&devices_out.stdout);
let mut items = Vec::new();
for line in stdout.lines() {
if line.starts_with("Device ") {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() == 3 {
// Format: "Alias (MAC)"
items.push(format!("{} ({})", parts[2], parts[1]));
}
}
}
if !items.is_empty() {
if let Ok(selected) = utils::show_menu("Connect BT:", &items, &config.general.menu_command) {
if let Some(mac_start) = selected.rfind('(') {
if let Some(mac_end) = selected.rfind(')') {
let mac = &selected[mac_start + 1..mac_end];
let _ = std::process::Command::new("bluetoothctl")
.args(["connect", mac])
.status();
}
}
}
}
return; // Exit client after menu
}
handle_ipc_response(ipc::request_data("bt", &[action.clone()]));
}
Commands::Buds { action } => handle_ipc_response(ipc::request_data("buds", &[action.clone()])),
Commands::Power => handle_ipc_response(ipc::request_data("power", &[])),
Commands::Game => handle_ipc_response(ipc::request_data("game", &[])),
@@ -129,13 +167,7 @@ fn handle_ipc_response(response: anyhow::Result<String>) {
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()) {
// Intelligent formatting:
// Only replace spaces with Figure Spaces if they are part of padding (surrounded by spaces or at start).
// If the text contains '<', we assume it's Pango markup and perform a safer replacement
// that avoids breaking tags.
let processed_text = if text.contains('<') {
// If it's markup, we only wrap in sentinels.
// Individual modules with markup shouldn't really have variable-width spaces inside tags.
text.to_string()
} else {
text.replace(' ', "\u{2007}")

View File

@@ -8,7 +8,7 @@ use std::process::Command;
pub struct AudioModule;
impl WaybarModule for AudioModule {
fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
let target_type = args.first().unwrap_or(&"sink");
let action = args.get(1).unwrap_or(&"show");
@@ -23,23 +23,21 @@ impl WaybarModule for AudioModule {
});
}
"show" | _ => {
self.get_status(target_type)
self.get_status(config, target_type)
}
}
}
}
impl AudioModule {
fn get_status(&self, target_type: &str) -> Result<WaybarOutput> {
fn get_status(&self, config: &Config, target_type: &str) -> Result<WaybarOutput> {
let target = if target_type == "sink" { "@DEFAULT_AUDIO_SINK@" } else { "@DEFAULT_AUDIO_SOURCE@" };
// Get volume and mute status via wpctl (faster than pactl for this)
let output = Command::new("wpctl")
.args(["get-volume", target])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Output format: "Volume: 0.50" or "Volume: 0.50 [MUTED]"
let parts: Vec<&str> = stdout.trim().split_whitespace().collect();
if parts.len() < 2 {
return Err(anyhow!("Could not parse wpctl output: {}", stdout));
@@ -59,7 +57,8 @@ impl AudioModule {
let (text, class) = if muted {
let icon = if target_type == "sink" { "" } else { "" };
(format!("{} {}", name, icon), "muted")
let format_str = if target_type == "sink" { &config.audio.format_sink_muted } else { &config.audio.format_source_muted };
(format_str.replace("{name}", &name).replace("{icon}", icon), "muted")
} else {
let icon = if target_type == "sink" {
if display_vol <= 30 { "" }
@@ -68,7 +67,13 @@ impl AudioModule {
} else {
""
};
(format!("{} {}% {}", name, display_vol, icon), "unmuted")
let format_str = if target_type == "sink" { &config.audio.format_sink_unmuted } else { &config.audio.format_source_unmuted };
let t = format_str
.replace("{name}", &name)
.replace("{icon}", icon)
.replace("{volume:>3}", &format!("{:>3}", display_vol))
.replace("{volume}", &format!("{}", display_vol));
(t, "unmuted")
};
Ok(WaybarOutput {
@@ -80,7 +85,6 @@ impl AudioModule {
}
fn get_description(&self, target_type: &str) -> Result<String> {
// Get the default device name
let info_output = Command::new("pactl").arg("info").output()?;
let info_stdout = String::from_utf8_lossy(&info_output.stdout);
let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" };
@@ -91,7 +95,6 @@ impl AudioModule {
.map(|s| s.trim())
.ok_or_else(|| anyhow!("Default {} not found", target_type))?;
// Get the description of that device
let list_cmd = if target_type == "sink" { "sinks" } else { "sources" };
let list_output = Command::new("pactl").args(["list", list_cmd]).output()?;
let list_stdout = String::from_utf8_lossy(&list_output.stdout);

View File

@@ -8,7 +8,7 @@ use std::process::Command;
pub struct BtModule;
impl WaybarModule for BtModule {
fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
let action = args.first().unwrap_or(&"show");
if *action == "disconnect" {
@@ -23,12 +23,11 @@ impl WaybarModule for BtModule {
});
}
// Check if bluetooth is powered on
if let Ok(output) = Command::new("bluetoothctl").arg("show").output() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Powered: no") {
return Ok(WaybarOutput {
text: "󰂲 Off".to_string(),
text: config.bt.format_disabled.clone(),
tooltip: Some("Bluetooth Disabled".to_string()),
class: Some("disabled".to_string()),
percentage: None,
@@ -64,16 +63,17 @@ impl WaybarModule for BtModule {
battery.map(|b| format!("{}%", b)).unwrap_or_else(|| "N/A".to_string())
);
let text = config.bt.format_connected.replace("{alias}", &alias);
Ok(WaybarOutput {
text: format!("{} 󰂰", alias),
text,
tooltip: Some(tooltip),
class: Some("connected".to_string()),
percentage: battery,
})
} else {
// No device connected but Bluetooth is on
Ok(WaybarOutput {
text: "󰂯".to_string(),
text: config.bt.format_disconnected.clone(),
tooltip: Some("Bluetooth On (Disconnected)".to_string()),
class: Some("disconnected".to_string()),
percentage: None,
@@ -83,7 +83,6 @@ impl WaybarModule for BtModule {
}
fn find_audio_device() -> Option<String> {
// 1. Try to check if current default sink is a bluetooth device
if let Ok(output) = Command::new("pactl").arg("get-default-sink").output() {
let sink = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sink.starts_with("bluez_output.") {
@@ -94,7 +93,6 @@ fn find_audio_device() -> Option<String> {
}
}
// 2. Fallback: Search bluetoothctl for connected devices with Audio Sink UUID
if let Ok(output) = Command::new("bluetoothctl").args(["devices", "Connected"]).output() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
@@ -104,7 +102,7 @@ fn find_audio_device() -> Option<String> {
let mac = parts[1];
if let Ok(info) = Command::new("bluetoothctl").args(["info", mac]).output() {
let info_str = String::from_utf8_lossy(&info.stdout);
if info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") { // Audio Sink UUID
if info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") {
return Some(mac.to_string());
}
}

View File

@@ -20,56 +20,48 @@ impl WaybarModule for BudsModule {
let next_mode = match current_mode.as_str() {
"active" => "aware",
"aware" => "off",
_ => "active", // default or off goes to active
_ => "active",
};
Command::new("pbpctrl").args(["set", "anc", next_mode]).status()?;
return Ok(WaybarOutput {
text: String::new(),
tooltip: None,
class: None,
percentage: None,
tooltip: None, class: None, percentage: None,
});
}
"connect" => {
Command::new("bluetoothctl").args(["connect", mac]).status()?;
return Ok(WaybarOutput {
text: String::new(),
tooltip: None,
class: None,
percentage: None,
tooltip: None, class: None, percentage: None,
});
}
"disconnect" => {
Command::new("bluetoothctl").args(["disconnect", mac]).status()?;
return Ok(WaybarOutput {
text: String::new(),
tooltip: None,
class: None,
percentage: None,
tooltip: None, class: None, percentage: None,
});
}
"show" | _ => {}
}
// Check if connected
let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?;
let bt_str = String::from_utf8_lossy(&bt_info.stdout);
if !bt_str.contains("Connected: yes") {
return Ok(WaybarOutput {
text: "<span size='large'></span>".to_string(),
text: config.buds.format_disconnected.clone(),
tooltip: Some("Pixel Buds Pro 2 not connected".to_string()),
class: Some("disconnected".to_string()),
percentage: None,
});
}
// Get battery output
let bat_cmd = Command::new("pbpctrl").args(["show", "battery"]).output();
if bat_cmd.is_err() || !bat_cmd.as_ref().unwrap().status.success() {
return Ok(WaybarOutput {
text: "<span size='large'></span>".to_string(),
text: config.buds.format_disconnected.clone(),
tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()),
class: Some("disconnected".to_string()),
percentage: None,
@@ -92,16 +84,13 @@ impl WaybarModule for BudsModule {
if left_bud == "unknown" && right_bud == "unknown" {
return Ok(WaybarOutput {
text: "{}".to_string(),
tooltip: None,
class: None,
percentage: None,
tooltip: None, class: None, percentage: None,
});
}
let left_display = if left_bud == "unknown" { "L: ---".to_string() } else { format!("L: {}", left_bud) };
let right_display = if right_bud == "unknown" { "R: ---".to_string() } else { format!("R: {}", right_bud) };
let left_display = if left_bud == "unknown" { "---".to_string() } else { format!("{}%", left_bud) };
let right_display = if right_bud == "unknown" { "---".to_string() } else { format!("{}%", right_bud) };
// Get ANC info
let anc_cmd = Command::new("pbpctrl").args(["get", "anc"]).output()?;
let current_mode = String::from_utf8_lossy(&anc_cmd.stdout).trim().to_string();
@@ -112,8 +101,13 @@ impl WaybarModule for BudsModule {
_ => ("?", "anc-unknown"),
};
let text = config.buds.format
.replace("{left}", &left_display)
.replace("{right}", &right_display)
.replace("{anc}", anc_icon);
Ok(WaybarOutput {
text: format!("{} | {} | {}", left_display, right_display, anc_icon),
text,
tooltip: Some("Pixel Buds Pro 2".to_string()),
class: Some(class.to_string()),
percentage: None,

View File

@@ -8,19 +8,15 @@ use std::process::Command;
pub struct GameModule;
impl WaybarModule for GameModule {
fn run(&self, _config: &Config, _state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
fn run(&self, config: &Config, _state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
let output = Command::new("hyprctl")
.args(["getoption", "animations:enabled", "-j"])
.output();
let mut is_gamemode = false; // default to deactivated
let mut is_gamemode = false;
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
// The JSON from hyprctl looks like {"int": 0, "float": 0.0, ...}
// If int is 0, animations are disabled (Gamemode active)
// If int is 1, animations are enabled (Gamemode deactivated)
if stdout.contains("\"int\": 0") {
is_gamemode = true;
}
@@ -28,14 +24,14 @@ impl WaybarModule for GameModule {
if is_gamemode {
Ok(WaybarOutput {
text: "<span size='large'>󰊖</span>".to_string(),
text: config.game.format_active.clone(),
tooltip: Some("Gamemode activated".to_string()),
class: Some("active".to_string()),
percentage: None,
})
} else {
Ok(WaybarOutput {
text: "<span size='large'></span>".to_string(),
text: config.game.format_inactive.clone(),
tooltip: Some("Gamemode deactivated".to_string()),
class: None,
percentage: None,

32
src/utils.rs Normal file
View File

@@ -0,0 +1,32 @@
use anyhow::{Context, Result};
use std::io::Write;
use std::process::{Command, Stdio};
pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result<String> {
let cmd_str = menu_cmd.replace("{prompt}", prompt);
let mut child = Command::new("sh")
.arg("-c")
.arg(&cmd_str)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context("Failed to spawn menu command")?;
if let Some(mut stdin) = child.stdin.take() {
let input = items.join("\n");
stdin.write_all(input.as_bytes()).context("Failed to write to menu stdin")?;
}
let output = child.wait_with_output().context("Failed to wait on menu")?;
if !output.status.success() {
return Err(anyhow::anyhow!("Menu cancelled or failed"));
}
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if selected.is_empty() {
return Err(anyhow::anyhow!("No item selected"));
}
Ok(selected)
}