added menu functionality (default fuzzel)
This commit is contained in:
101
README.md
101
README.md
@@ -1,58 +1,59 @@
|
|||||||
# fluxo-rs
|
# fluxo-rs
|
||||||
|
|
||||||
fluxo-rs is a high-performance system metrics daemon and client designed specifically for waybar. it replaces standard shell scripts with a compiled rust binary that collects data via a background polling loop and serves it over a unix domain socket (/tmp/fluxo.sock).
|
fluxo-rs is a high-performance system metrics daemon and client designed specifically for waybar. It replaces standard shell scripts with a compiled rust binary that collects data via a background polling loop and serves it over a unix domain socket (`/tmp/fluxo.sock`).
|
||||||
|
|
||||||
## description
|
## Description
|
||||||
|
|
||||||
the project follows a client-server architecture:
|
The project follows a client-server architecture:
|
||||||
- daemon: handles heavy lifting (polling cpu, memory, network, gpu) and stores state in memory.
|
- **Daemon**: Handles heavy lifting (polling cpu, memory, network, gpu) and stores state in memory.
|
||||||
- client: a thin cli wrapper that connects to the daemon's socket to retrieve formatted json for waybar.
|
- **Client**: A thin cli wrapper that connects to the daemon's socket to retrieve formatted json for waybar.
|
||||||
|
|
||||||
this approach eliminates process spawning overhead and temporary file locking, resulting in near-zero cpu usage for custom modules.
|
This approach eliminates process spawning overhead and temporary file locking, resulting in near-zero cpu usage for custom modules.
|
||||||
|
|
||||||
## features
|
## Features
|
||||||
|
|
||||||
- ultra-lightweight: background polling is highly optimized (e.g., O(1) process counting).
|
- **Ultra-lightweight**: Background polling is highly optimized (e.g., O(1) process counting).
|
||||||
- jitter-free: uses zero-width sentinels and figure spaces to prevent waybar from trimming padding.
|
- **Jitter-free**: Uses zero-width sentinels and figure spaces to prevent waybar from trimming padding.
|
||||||
- configurable: customizable output formats via toml config.
|
- **Configurable**: Fully customizable output formats via toml config.
|
||||||
- live reload: configuration can be reloaded without restarting the daemon.
|
- **Interactive Menus**: Integrated support for selecting items (like Bluetooth devices) via external menus (e.g., Rofi, Wofi).
|
||||||
- multi-vendor gpu: native support for intel (igpu), amd, and nvidia.
|
- **Live Reload**: Configuration can be reloaded without restarting the daemon.
|
||||||
|
- **Multi-vendor GPU**: Native support for intel (igpu), amd, and nvidia.
|
||||||
|
|
||||||
## modules
|
## Modules
|
||||||
|
|
||||||
| command | description | tokens |
|
| Command | Description | Tokens |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `net` | network speed (rx/tx) | `{interface}`, `{ip}`, `{rx}`, `{tx}` |
|
| `net` | Network speed (rx/tx) | `{interface}`, `{ip}`, `{rx}`, `{tx}` |
|
||||||
| `cpu` | cpu usage and temp | `{usage}`, `{temp}` |
|
| `cpu` | CPU usage and temp | `{usage}`, `{temp}` |
|
||||||
| `mem` | memory usage | `{used}`, `{total}` |
|
| `mem` | Memory usage | `{used}`, `{total}` |
|
||||||
| `gpu` | gpu utilization | `{usage}`, `{vram_used}`, `{vram_total}`, `{temp}` |
|
| `gpu` | GPU utilization | `{usage}`, `{vram_used}`, `{vram_total}`, `{temp}` |
|
||||||
| `sys` | system load and uptime | `{uptime}`, `{load1}`, `{load5}`, `{load15}` |
|
| `sys` | System load and uptime | `{uptime}`, `{load1}`, `{load5}`, `{load15}` |
|
||||||
| `disk` | disk usage (default: /) | `{mount}`, `{used}`, `{total}` |
|
| `disk` | Disk usage (default: /) | `{mount}`, `{used}`, `{total}` |
|
||||||
| `pool` | aggregate storage (btrfs) | `{used}`, `{total}` |
|
| `pool` | Aggregate storage (btrfs) | `{used}`, `{total}` |
|
||||||
| `vol` | audio output volume | `{percentage}`, `{icon}` |
|
| `vol` | Audio output volume | `{name}`, `{volume}`, `{icon}` |
|
||||||
| `mic` | audio input volume | `{percentage}`, `{icon}` |
|
| `mic` | Audio input volume | `{name}`, `{volume}`, `{icon}` |
|
||||||
| `bt` | bluetooth status | device name and battery |
|
| `bt` | Bluetooth status | `{alias}` |
|
||||||
| `buds` | pixel buds pro control | left/right battery and anc state |
|
| `buds` | Pixel Buds Pro control | `{left}`, `{right}`, `{anc}` |
|
||||||
| `power` | battery and ac status | `{percentage}`, `{icon}` |
|
| `power` | Battery and AC status | `{percentage}`, `{icon}` |
|
||||||
| `game` | hyprland gamemode status | active/inactive icon |
|
| `game` | Hyprland gamemode status | active/inactive icon strings |
|
||||||
|
|
||||||
## setup
|
## Setup
|
||||||
|
|
||||||
1. build the project:
|
1. Build the project:
|
||||||
```bash
|
```bash
|
||||||
cd fluxo-rs
|
cd fluxo-rs
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
2. start the daemon:
|
2. Start the daemon:
|
||||||
```bash
|
```bash
|
||||||
./target/release/fluxo-rs daemon &
|
./target/release/fluxo-rs daemon &
|
||||||
```
|
```
|
||||||
|
|
||||||
3. configuration:
|
3. Configuration:
|
||||||
create `~/.config/fluxo/config.toml` (see `example.config.toml` for all options).
|
Create `~/.config/fluxo/config.toml` (see `example.config.toml` for all default options).
|
||||||
|
|
||||||
4. waybar configuration (`config.jsonc`):
|
4. Waybar configuration (`config.jsonc`):
|
||||||
```json
|
```json
|
||||||
"custom/cpu": {
|
"custom/cpu": {
|
||||||
"exec": "~/path/to/fluxo-rs cpu",
|
"exec": "~/path/to/fluxo-rs cpu",
|
||||||
@@ -60,29 +61,31 @@ this approach eliminates process spawning overhead and temporary file locking, r
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## development
|
## Development
|
||||||
|
|
||||||
### architecture
|
### Architecture
|
||||||
- `src/main.rs`: entry point, cli parsing, and client-side formatting logic.
|
- `src/main.rs`: Entry point, CLI parsing, interactive GUI spawns (menus), and client-side formatting logic.
|
||||||
- `src/daemon.rs`:uds listener, configuration management, and polling orchestration.
|
- `src/daemon.rs`: UDS listener, configuration management, and polling orchestration.
|
||||||
- `src/ipc.rs`: unix domain socket communication logic.
|
- `src/ipc.rs`: Unix domain socket communication logic.
|
||||||
- `src/modules/`: individual metric implementations.
|
- `src/utils.rs`: Generic GUI utilities (like the menu spawner).
|
||||||
- `src/state.rs`: shared thread-safe data structures.
|
- `src/modules/`: Individual metric implementations.
|
||||||
|
- `src/state.rs`: Shared thread-safe data structures.
|
||||||
|
|
||||||
### adding a module
|
### Adding a Module
|
||||||
1. add the required fields to `src/state.rs`.
|
1. Add the required config block to `src/config.rs`.
|
||||||
2. implement the `WaybarModule` trait in a new file in `src/modules/`.
|
2. Add the required state fields to `src/state.rs`.
|
||||||
3. add polling logic to `src/modules/hardware.rs` or `src/daemon.rs`.
|
3. Implement the `WaybarModule` trait in a new file in `src/modules/`.
|
||||||
4. register the new subcommand in `src/main.rs` and the router in `src/daemon.rs`.
|
4. Add polling logic to `src/modules/hardware.rs` or `src/daemon.rs`.
|
||||||
|
5. Register the new subcommand in `src/main.rs` and the router in `src/daemon.rs`.
|
||||||
|
|
||||||
### configuration reload
|
### Configuration Reload
|
||||||
the daemon can reload its configuration live:
|
The daemon can reload its configuration live:
|
||||||
```bash
|
```bash
|
||||||
fluxo-rs reload
|
fluxo-rs reload
|
||||||
```
|
```
|
||||||
|
|
||||||
### logs
|
### Logs
|
||||||
run the daemon with debug logs for troubleshooting:
|
Run the daemon with debug logs for troubleshooting:
|
||||||
```bash
|
```bash
|
||||||
RUST_LOG=debug fluxo-rs daemon
|
RUST_LOG=debug fluxo-rs daemon
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# fluxo-rs example configuration
|
# fluxo-rs example configuration
|
||||||
# place this at ~/.config/fluxo/config.toml
|
# place this at ~/.config/fluxo/config.toml
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# command used for interactive menus (e.g., bluetooth device selection)
|
||||||
|
# tokens: {prompt}
|
||||||
|
menu_command = "fuzzel --dmenu --prompt '{prompt}'"
|
||||||
|
|
||||||
# network module (net)
|
# network module (net)
|
||||||
# tokens: {interface}, {ip}, {rx}, {tx}, {rx:>5.2}, {tx:>5.2}
|
# tokens: {interface}, {ip}, {rx}, {tx}, {rx:>5.2}, {tx:>5.2}
|
||||||
[network]
|
[network]
|
||||||
@@ -42,3 +47,30 @@ format = "{used:>4.0}G / {total:>4.0}G"
|
|||||||
# tokens: {percentage}, {icon}, {percentage:>3}
|
# tokens: {percentage}, {icon}, {percentage:>3}
|
||||||
[power]
|
[power]
|
||||||
format = "{percentage:>3}% {icon}"
|
format = "{percentage:>3}% {icon}"
|
||||||
|
|
||||||
|
# audio module (vol / mic)
|
||||||
|
# tokens: {name}, {volume}, {icon}, {volume:>3}
|
||||||
|
[audio]
|
||||||
|
format_sink_unmuted = "{name} {volume:>3}% {icon}"
|
||||||
|
format_sink_muted = "{name} {icon}"
|
||||||
|
format_source_unmuted = "{name} {volume:>3}% {icon}"
|
||||||
|
format_source_muted = "{name} {icon}"
|
||||||
|
|
||||||
|
# bluetooth module (bt)
|
||||||
|
# tokens: {alias}
|
||||||
|
[bt]
|
||||||
|
format_connected = "{alias} "
|
||||||
|
format_disconnected = ""
|
||||||
|
format_disabled = " Off"
|
||||||
|
|
||||||
|
# pixel buds module (buds)
|
||||||
|
# tokens: {left}, {right}, {anc}
|
||||||
|
[buds]
|
||||||
|
mac = "B4:23:A2:09:D3:53"
|
||||||
|
format = "{left} | {right} | {anc}"
|
||||||
|
format_disconnected = "<span size='large'></span>"
|
||||||
|
|
||||||
|
# gamemode module (game)
|
||||||
|
[game]
|
||||||
|
format_active = "<span size='large'></span>"
|
||||||
|
format_inactive = "<span size='large'></span>"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub general: GeneralConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network: NetworkConfig,
|
pub network: NetworkConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -22,6 +24,25 @@ pub struct Config {
|
|||||||
pub power: PowerConfig,
|
pub power: PowerConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub buds: BudsConfig,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -135,12 +156,67 @@ impl Default for PowerConfig {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct BudsConfig {
|
pub struct BudsConfig {
|
||||||
pub mac: String,
|
pub mac: String,
|
||||||
|
pub format: String,
|
||||||
|
pub format_disconnected: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BudsConfig {
|
impl Default for BudsConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mac: "B4:23:A2:09:D3:53".to_string(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/main.rs
46
src/main.rs
@@ -4,6 +4,7 @@ mod ipc;
|
|||||||
mod modules;
|
mod modules;
|
||||||
mod output;
|
mod output;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::process;
|
use std::process;
|
||||||
@@ -116,7 +117,44 @@ fn main() {
|
|||||||
}
|
}
|
||||||
Commands::Gpu => handle_ipc_response(ipc::request_data("gpu", &[])),
|
Commands::Gpu => handle_ipc_response(ipc::request_data("gpu", &[])),
|
||||||
Commands::Sys => handle_ipc_response(ipc::request_data("sys", &[])),
|
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::Buds { action } => handle_ipc_response(ipc::request_data("buds", &[action.clone()])),
|
||||||
Commands::Power => handle_ipc_response(ipc::request_data("power", &[])),
|
Commands::Power => handle_ipc_response(ipc::request_data("power", &[])),
|
||||||
Commands::Game => handle_ipc_response(ipc::request_data("game", &[])),
|
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) {
|
match serde_json::from_str::<serde_json::Value>(&json_str) {
|
||||||
Ok(mut val) => {
|
Ok(mut val) => {
|
||||||
if let Some(text) = val.get_mut("text").and_then(|t| t.as_str()) {
|
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('<') {
|
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()
|
text.to_string()
|
||||||
} else {
|
} else {
|
||||||
text.replace(' ', "\u{2007}")
|
text.replace(' ', "\u{2007}")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::process::Command;
|
|||||||
pub struct AudioModule;
|
pub struct AudioModule;
|
||||||
|
|
||||||
impl WaybarModule for 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 target_type = args.first().unwrap_or(&"sink");
|
||||||
let action = args.get(1).unwrap_or(&"show");
|
let action = args.get(1).unwrap_or(&"show");
|
||||||
|
|
||||||
@@ -23,23 +23,21 @@ impl WaybarModule for AudioModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
"show" | _ => {
|
"show" | _ => {
|
||||||
self.get_status(target_type)
|
self.get_status(config, target_type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioModule {
|
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@" };
|
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")
|
let output = Command::new("wpctl")
|
||||||
.args(["get-volume", target])
|
.args(["get-volume", target])
|
||||||
.output()?;
|
.output()?;
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
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();
|
let parts: Vec<&str> = stdout.trim().split_whitespace().collect();
|
||||||
if parts.len() < 2 {
|
if parts.len() < 2 {
|
||||||
return Err(anyhow!("Could not parse wpctl output: {}", stdout));
|
return Err(anyhow!("Could not parse wpctl output: {}", stdout));
|
||||||
@@ -59,7 +57,8 @@ impl AudioModule {
|
|||||||
|
|
||||||
let (text, class) = if muted {
|
let (text, class) = if muted {
|
||||||
let icon = if target_type == "sink" { "" } else { "" };
|
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 {
|
} else {
|
||||||
let icon = if target_type == "sink" {
|
let icon = if target_type == "sink" {
|
||||||
if display_vol <= 30 { "" }
|
if display_vol <= 30 { "" }
|
||||||
@@ -68,7 +67,13 @@ impl AudioModule {
|
|||||||
} else {
|
} 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 {
|
Ok(WaybarOutput {
|
||||||
@@ -80,7 +85,6 @@ impl AudioModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_description(&self, target_type: &str) -> Result<String> {
|
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_output = Command::new("pactl").arg("info").output()?;
|
||||||
let info_stdout = String::from_utf8_lossy(&info_output.stdout);
|
let info_stdout = String::from_utf8_lossy(&info_output.stdout);
|
||||||
let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" };
|
let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" };
|
||||||
@@ -91,7 +95,6 @@ impl AudioModule {
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.ok_or_else(|| anyhow!("Default {} not found", target_type))?;
|
.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_cmd = if target_type == "sink" { "sinks" } else { "sources" };
|
||||||
let list_output = Command::new("pactl").args(["list", list_cmd]).output()?;
|
let list_output = Command::new("pactl").args(["list", list_cmd]).output()?;
|
||||||
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
|
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::process::Command;
|
|||||||
pub struct BtModule;
|
pub struct BtModule;
|
||||||
|
|
||||||
impl WaybarModule for 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");
|
let action = args.first().unwrap_or(&"show");
|
||||||
|
|
||||||
if *action == "disconnect" {
|
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() {
|
if let Ok(output) = Command::new("bluetoothctl").arg("show").output() {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
if stdout.contains("Powered: no") {
|
if stdout.contains("Powered: no") {
|
||||||
return Ok(WaybarOutput {
|
return Ok(WaybarOutput {
|
||||||
text: " Off".to_string(),
|
text: config.bt.format_disabled.clone(),
|
||||||
tooltip: Some("Bluetooth Disabled".to_string()),
|
tooltip: Some("Bluetooth Disabled".to_string()),
|
||||||
class: Some("disabled".to_string()),
|
class: Some("disabled".to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
@@ -64,16 +63,17 @@ impl WaybarModule for BtModule {
|
|||||||
battery.map(|b| format!("{}%", b)).unwrap_or_else(|| "N/A".to_string())
|
battery.map(|b| format!("{}%", b)).unwrap_or_else(|| "N/A".to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let text = config.bt.format_connected.replace("{alias}", &alias);
|
||||||
|
|
||||||
Ok(WaybarOutput {
|
Ok(WaybarOutput {
|
||||||
text: format!("{} ", alias),
|
text,
|
||||||
tooltip: Some(tooltip),
|
tooltip: Some(tooltip),
|
||||||
class: Some("connected".to_string()),
|
class: Some("connected".to_string()),
|
||||||
percentage: battery,
|
percentage: battery,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// No device connected but Bluetooth is on
|
|
||||||
Ok(WaybarOutput {
|
Ok(WaybarOutput {
|
||||||
text: "".to_string(),
|
text: config.bt.format_disconnected.clone(),
|
||||||
tooltip: Some("Bluetooth On (Disconnected)".to_string()),
|
tooltip: Some("Bluetooth On (Disconnected)".to_string()),
|
||||||
class: Some("disconnected".to_string()),
|
class: Some("disconnected".to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
@@ -83,7 +83,6 @@ impl WaybarModule for BtModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_audio_device() -> Option<String> {
|
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() {
|
if let Ok(output) = Command::new("pactl").arg("get-default-sink").output() {
|
||||||
let sink = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let sink = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
if sink.starts_with("bluez_output.") {
|
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() {
|
if let Ok(output) = Command::new("bluetoothctl").args(["devices", "Connected"]).output() {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
for line in stdout.lines() {
|
for line in stdout.lines() {
|
||||||
@@ -104,7 +102,7 @@ fn find_audio_device() -> Option<String> {
|
|||||||
let mac = parts[1];
|
let mac = parts[1];
|
||||||
if let Ok(info) = Command::new("bluetoothctl").args(["info", mac]).output() {
|
if let Ok(info) = Command::new("bluetoothctl").args(["info", mac]).output() {
|
||||||
let info_str = String::from_utf8_lossy(&info.stdout);
|
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());
|
return Some(mac.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,56 +20,48 @@ impl WaybarModule for BudsModule {
|
|||||||
let next_mode = match current_mode.as_str() {
|
let next_mode = match current_mode.as_str() {
|
||||||
"active" => "aware",
|
"active" => "aware",
|
||||||
"aware" => "off",
|
"aware" => "off",
|
||||||
_ => "active", // default or off goes to active
|
_ => "active",
|
||||||
};
|
};
|
||||||
|
|
||||||
Command::new("pbpctrl").args(["set", "anc", next_mode]).status()?;
|
Command::new("pbpctrl").args(["set", "anc", next_mode]).status()?;
|
||||||
return Ok(WaybarOutput {
|
return Ok(WaybarOutput {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
tooltip: None,
|
tooltip: None, class: None, percentage: None,
|
||||||
class: None,
|
|
||||||
percentage: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"connect" => {
|
"connect" => {
|
||||||
Command::new("bluetoothctl").args(["connect", mac]).status()?;
|
Command::new("bluetoothctl").args(["connect", mac]).status()?;
|
||||||
return Ok(WaybarOutput {
|
return Ok(WaybarOutput {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
tooltip: None,
|
tooltip: None, class: None, percentage: None,
|
||||||
class: None,
|
|
||||||
percentage: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"disconnect" => {
|
"disconnect" => {
|
||||||
Command::new("bluetoothctl").args(["disconnect", mac]).status()?;
|
Command::new("bluetoothctl").args(["disconnect", mac]).status()?;
|
||||||
return Ok(WaybarOutput {
|
return Ok(WaybarOutput {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
tooltip: None,
|
tooltip: None, class: None, percentage: None,
|
||||||
class: None,
|
|
||||||
percentage: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"show" | _ => {}
|
"show" | _ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connected
|
|
||||||
let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?;
|
let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?;
|
||||||
let bt_str = String::from_utf8_lossy(&bt_info.stdout);
|
let bt_str = String::from_utf8_lossy(&bt_info.stdout);
|
||||||
|
|
||||||
if !bt_str.contains("Connected: yes") {
|
if !bt_str.contains("Connected: yes") {
|
||||||
return Ok(WaybarOutput {
|
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()),
|
tooltip: Some("Pixel Buds Pro 2 not connected".to_string()),
|
||||||
class: Some("disconnected".to_string()),
|
class: Some("disconnected".to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get battery output
|
|
||||||
let bat_cmd = Command::new("pbpctrl").args(["show", "battery"]).output();
|
let bat_cmd = Command::new("pbpctrl").args(["show", "battery"]).output();
|
||||||
if bat_cmd.is_err() || !bat_cmd.as_ref().unwrap().status.success() {
|
if bat_cmd.is_err() || !bat_cmd.as_ref().unwrap().status.success() {
|
||||||
return Ok(WaybarOutput {
|
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()),
|
tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()),
|
||||||
class: Some("disconnected".to_string()),
|
class: Some("disconnected".to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
@@ -92,16 +84,13 @@ impl WaybarModule for BudsModule {
|
|||||||
if left_bud == "unknown" && right_bud == "unknown" {
|
if left_bud == "unknown" && right_bud == "unknown" {
|
||||||
return Ok(WaybarOutput {
|
return Ok(WaybarOutput {
|
||||||
text: "{}".to_string(),
|
text: "{}".to_string(),
|
||||||
tooltip: None,
|
tooltip: None, class: None, percentage: None,
|
||||||
class: None,
|
|
||||||
percentage: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let left_display = if left_bud == "unknown" { "L: ---".to_string() } else { format!("L: {}", left_bud) };
|
let left_display = if left_bud == "unknown" { "---".to_string() } else { format!("{}%", left_bud) };
|
||||||
let right_display = if right_bud == "unknown" { "R: ---".to_string() } else { format!("R: {}", right_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 anc_cmd = Command::new("pbpctrl").args(["get", "anc"]).output()?;
|
||||||
let current_mode = String::from_utf8_lossy(&anc_cmd.stdout).trim().to_string();
|
let current_mode = String::from_utf8_lossy(&anc_cmd.stdout).trim().to_string();
|
||||||
|
|
||||||
@@ -112,8 +101,13 @@ impl WaybarModule for BudsModule {
|
|||||||
_ => ("?", "anc-unknown"),
|
_ => ("?", "anc-unknown"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let text = config.buds.format
|
||||||
|
.replace("{left}", &left_display)
|
||||||
|
.replace("{right}", &right_display)
|
||||||
|
.replace("{anc}", anc_icon);
|
||||||
|
|
||||||
Ok(WaybarOutput {
|
Ok(WaybarOutput {
|
||||||
text: format!("{} | {} | {}", left_display, right_display, anc_icon),
|
text,
|
||||||
tooltip: Some("Pixel Buds Pro 2".to_string()),
|
tooltip: Some("Pixel Buds Pro 2".to_string()),
|
||||||
class: Some(class.to_string()),
|
class: Some(class.to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
|
|||||||
@@ -8,19 +8,15 @@ use std::process::Command;
|
|||||||
pub struct GameModule;
|
pub struct GameModule;
|
||||||
|
|
||||||
impl WaybarModule for 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")
|
let output = Command::new("hyprctl")
|
||||||
.args(["getoption", "animations:enabled", "-j"])
|
.args(["getoption", "animations:enabled", "-j"])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
let mut is_gamemode = false; // default to deactivated
|
let mut is_gamemode = false;
|
||||||
|
|
||||||
if let Ok(out) = output {
|
if let Ok(out) = output {
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
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") {
|
if stdout.contains("\"int\": 0") {
|
||||||
is_gamemode = true;
|
is_gamemode = true;
|
||||||
}
|
}
|
||||||
@@ -28,14 +24,14 @@ impl WaybarModule for GameModule {
|
|||||||
|
|
||||||
if is_gamemode {
|
if is_gamemode {
|
||||||
Ok(WaybarOutput {
|
Ok(WaybarOutput {
|
||||||
text: "<span size='large'></span>".to_string(),
|
text: config.game.format_active.clone(),
|
||||||
tooltip: Some("Gamemode activated".to_string()),
|
tooltip: Some("Gamemode activated".to_string()),
|
||||||
class: Some("active".to_string()),
|
class: Some("active".to_string()),
|
||||||
percentage: None,
|
percentage: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(WaybarOutput {
|
Ok(WaybarOutput {
|
||||||
text: "<span size='large'></span>".to_string(),
|
text: config.game.format_inactive.clone(),
|
||||||
tooltip: Some("Gamemode deactivated".to_string()),
|
tooltip: Some("Gamemode deactivated".to_string()),
|
||||||
class: None,
|
class: None,
|
||||||
percentage: None,
|
percentage: None,
|
||||||
|
|||||||
32
src/utils.rs
Normal file
32
src/utils.rs
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user