New panels: About, Network (Wi-Fi/Ethernet via nmcli), Sound (PipeWire volume/device via pactl), Date & Time (timedatectl), and Clipboard (breadclip previously had no bos-settings presence at all despite running its own daemon). Layout: every panel now shares one scaffold (6 views were hand-rolling their own, inconsistently) with a centered, width-capped content column instead of rows stretching edge-to-edge on a wide window with the control stranded far from its label. Rows render as individual cards. Sidebar got human labels + icons + task-based grouping (System/Personalization/Maintenance/About) instead of raw binary names under "Apps"/"System". New capability: a shared service_control() widget gives every bread app backed by a systemd --user daemon (breadd, breadbox-sync, breadcrumbs, breadmill, breadclipd) live status plus Start/Stop/Restart/View logs — previously these panels only ever edited a TOML file and hoped the running process picked it up. breadbar (no systemd unit) gets an equivalent: Save now actually sends the SIGHUP its own reload mechanism needs, instead of just claiming to in the hint text. Fixed two real bugs surfaced while building About: CPU model string kept a stray leading tab+colon (trim_start_matches was missing '\t'), and GPU showed "unknown" on hardware whose lspci entry says "Display controller" instead of "VGA compatible controller". Local CSS patches (bos-settings/src/theme.rs) for two upstream bread-theme gaps: suggested/destructive buttons and scale widgets don't clear Adwaita's default background-image, so flat colour overrides were invisible; and destructive-action red must not be pywal-derived (it can land on gold depending on the wallpaper, indistinguishable from a primary action).
167 lines
5.4 KiB
Rust
167 lines
5.4 KiB
Rust
//! Output/input volume and device selection over PipeWire's pulse
|
|
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
|
|
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
|
|
//! because it can enumerate devices with human-readable descriptions and
|
|
//! switch the default in one command; `wpctl` cannot easily do either.
|
|
|
|
use gtk4::prelude::*;
|
|
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
|
|
use serde::Deserialize;
|
|
use std::process::Command;
|
|
|
|
use crate::ui::widgets as w;
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
struct Device {
|
|
name: String,
|
|
description: String,
|
|
mute: bool,
|
|
volume: std::collections::HashMap<String, VolumeChannel>,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
struct VolumeChannel {
|
|
value_percent: String,
|
|
}
|
|
|
|
impl Device {
|
|
fn percent(&self) -> f64 {
|
|
self.volume
|
|
.values()
|
|
.next()
|
|
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
|
|
.unwrap_or(0.0)
|
|
}
|
|
}
|
|
|
|
fn list_devices(kind: &str) -> Vec<Device> {
|
|
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
|
|
return Vec::new();
|
|
};
|
|
serde_json::from_slice(&output.stdout).unwrap_or_default()
|
|
}
|
|
|
|
fn default_device_name(kind: &str) -> Option<String> {
|
|
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
|
|
Command::new("pactl")
|
|
.arg(flag)
|
|
.output()
|
|
.ok()
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// Build one section (Output or Input): device dropdown + volume slider + mute
|
|
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
|
|
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
|
|
/// likewise.
|
|
fn build_section(
|
|
title: &str,
|
|
kind: &'static str,
|
|
set_default_flag: &'static str,
|
|
vol_flag: &'static str,
|
|
mute_flag: &'static str,
|
|
) -> GBox {
|
|
let section = GBox::new(Orientation::Vertical, 4);
|
|
section.append(&w::section(title));
|
|
|
|
let devices = list_devices(kind);
|
|
if devices.is_empty() {
|
|
section.append(&w::hint("No devices found."));
|
|
return section;
|
|
}
|
|
|
|
let current_name = default_device_name(kind);
|
|
let selected_idx = current_name
|
|
.as_ref()
|
|
.and_then(|n| devices.iter().position(|d| &d.name == n))
|
|
.unwrap_or(0);
|
|
let current = devices[selected_idx].clone();
|
|
|
|
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
|
|
let model = StringList::new(&labels);
|
|
let dd = DropDown::new(Some(model), Expression::NONE);
|
|
dd.set_selected(selected_idx as u32);
|
|
section.append(&w::row("Device", &dd));
|
|
|
|
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
|
|
scale.set_value(current.percent());
|
|
scale.set_size_request(180, -1);
|
|
scale.set_draw_value(true);
|
|
scale.set_value_pos(gtk4::PositionType::Right);
|
|
section.append(&w::row("Volume", &scale));
|
|
|
|
let mute_sw = Switch::new();
|
|
mute_sw.set_active(current.mute);
|
|
section.append(&w::row("Mute", &mute_sw));
|
|
|
|
// Device switch: fire-and-forget pactl call, then resync the slider/mute
|
|
// switch to whatever the newly-default device's actual state is.
|
|
{
|
|
let devices = devices.clone();
|
|
let scale = scale.clone();
|
|
let mute_sw = mute_sw.clone();
|
|
dd.connect_selected_notify(move |dd| {
|
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
|
|
scale.set_value(d.percent());
|
|
mute_sw.set_active(d.mute);
|
|
});
|
|
}
|
|
|
|
// Volume: target whichever device is currently selected in the dropdown,
|
|
// not necessarily the system default at the time this closure was built.
|
|
{
|
|
let devices = devices.clone();
|
|
let dd = dd.clone();
|
|
scale.connect_value_changed(move |s| {
|
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
let pct = format!("{}%", s.value() as i64);
|
|
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
|
|
});
|
|
}
|
|
|
|
{
|
|
let devices = devices.clone();
|
|
let dd = dd.clone();
|
|
mute_sw.connect_active_notify(move |s| {
|
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
|
let val = if s.is_active() { "1" } else { "0" };
|
|
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
|
|
});
|
|
}
|
|
|
|
section
|
|
}
|
|
|
|
pub fn build() -> GBox {
|
|
let (outer, content) = w::view_scaffold("Sound");
|
|
|
|
content.append(&build_section(
|
|
"Output",
|
|
"sinks",
|
|
"set-default-sink",
|
|
"set-sink-volume",
|
|
"set-sink-mute",
|
|
));
|
|
content.append(&build_section(
|
|
"Input",
|
|
"sources",
|
|
"set-default-source",
|
|
"set-source-volume",
|
|
"set-source-mute",
|
|
));
|
|
|
|
content.append(&w::section("Advanced"));
|
|
content.append(&w::hint(
|
|
"Per-app volume, port selection, and profile switching aren't covered here.",
|
|
));
|
|
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
|
|
mixer_btn.set_halign(gtk4::Align::Start);
|
|
mixer_btn.connect_clicked(|_| {
|
|
let _ = Command::new("pavucontrol").spawn();
|
|
});
|
|
content.append(&mixer_btn);
|
|
|
|
outer
|
|
}
|