control centre: menus for Wi-Fi, Bluetooth, VPN and Sound in their own window

The tile or block grows into its menu; the panel fades its content and takes the
menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop
iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has
every output with its own slider and a default picker. A second Sound slider for
the output named by extra_output in shell.conf (NR515). Screen and keyboard light
are written to sysfs directly: logind refuses a shell without a seat.
This commit is contained in:
server2
2026-10-10 12:13:34 +02:00
parent 1b571abef0
commit 318b2a8443
10 changed files with 1004 additions and 70 deletions
+787 -43
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -12,6 +12,8 @@ pub enum Glyph {
Muted,
Bluetooth,
Chevron,
// pointing left, for a menu's way back
Back,
Note,
// wi-fi with 0 to 3 of its arcs lit; the unlit ones are drawn faint
@@ -91,6 +93,9 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
Glyph::Chevron => shapes![
Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round)
],
Glyph::Back => shapes![
Path::new().move_to(0.64 * s, 0.14 * s).line_to(0.3 * s, 0.5 * s).line_to(0.64 * s, 0.86 * s).stroke(thin.max(1.6), color).cap(Cap::Round)
],
// an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![
Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color),
+2 -2
View File
@@ -23,8 +23,8 @@ use crate::theme;
* on the settled shape. closing runs the same timeline backwards
*/
const OPEN_MS: u64 = 560;
const PILL_GONE: f32 = 0.18;
const SHAPE_DONE: f32 = 0.6;
pub const PILL_GONE: f32 = 0.18;
pub const SHAPE_DONE: f32 = 0.6;
// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW
pub const HOVER_MS: u64 = 220;
+1
View File
@@ -24,5 +24,6 @@ fn main() {
.window_per_monitor(island::view)
.window_per_monitor(workspaces::view)
.window_per_monitor(control::view)
.window_per_monitor(control::menu_view)
.run();
}
+27
View File
@@ -37,6 +37,10 @@ pub struct Sink {
pub name: String,
pub description: String,
pub bluetooth: bool,
// first channel, in percent
pub volume: u8,
pub muted: bool,
}
pub fn sink_inputs(text: &str) -> Vec<Stream> {
@@ -97,6 +101,10 @@ pub fn sinks(text: &str) -> Vec<Sink> {
sink.name = name.to_string();
} else if let Some(description) = line.strip_prefix("Description: ") {
sink.description = description.to_string();
} else if let Some(mute) = line.strip_prefix("Mute: ") {
sink.muted = mute == "yes";
} else if let Some(volume) = line.strip_prefix("Volume: ") {
sink.volume = first_percent(volume);
} else if line == "device.api = \"bluez5\"" {
sink.bluetooth = true;
}
@@ -105,6 +113,17 @@ pub fn sinks(text: &str) -> Vec<Sink> {
sinks
}
/*
* what a person calls an output: the monitor or receiver an hdmi port names in brackets,
* "... DisplayPort 1 Output [TX-NR515] (Stereo)" gives "TX-NR515", else the description
*/
pub fn short_sink_name(description: &str) -> &str {
match (description.find('['), description.find(']')) {
(Some(open), Some(close)) if open < close => &description[open + 1..close],
_ => description,
}
}
// "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80
fn first_percent(volume: &str) -> u8 {
let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%'));
@@ -462,6 +481,9 @@ Sink Input #57
\tState: RUNNING
\tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo
\tDescription: Yamaha AG03MK2 Analog Stereo
\tMute: no
\tVolume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB
\tBase Volume: 65536 / 100% / 0.00 dB
\tProperties:
\t\tdevice.api = \"alsa\"
\tPorts:
@@ -470,6 +492,7 @@ Sink Input #57
Sink #80
\tName: bluez_output.00_1B_66_AA_BB_CC.1
\tDescription: JBL Tune 720BT
\tMute: yes
\tProperties:
\t\tdevice.api = \"bluez5\"
";
@@ -492,7 +515,11 @@ Sink #80
assert_eq!(sinks.len(), 2);
assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false));
assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo");
assert_eq!((sinks[0].volume, sinks[0].muted, sinks[1].muted), (80, false, true));
assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true));
assert_eq!(short_sink_name("700 Series Chipset Family HD Audio HDMI / DisplayPort 1 Output [TX-NR515] (Stereo)"), "TX-NR515");
assert_eq!(short_sink_name("Yamaha AG03MK2 Analog Stereo"), "Yamaha AG03MK2 Analog Stereo");
}
#[test]
+64 -1
View File
@@ -69,6 +69,9 @@ pub struct Sound {
outputs: Vec<Sink>,
channels: Vec<Channel>,
// the sink new sound goes to (`pactl get-default-sink`)
default_output: String,
// by player identity, so "Cider" keeps its curve across restarts
eqs: BTreeMap<String, Eq>,
unsaved: bool,
@@ -108,6 +111,63 @@ impl Sound {
&self.outputs
}
pub fn default_output(&self) -> &str {
&self.default_output
}
// the first output whose pactl name or description contains `wanted`; none for an empty `wanted`
pub fn output_named(&self, wanted: &str) -> Option<&Sink> {
let wanted = wanted.to_lowercase();
(!wanted.is_empty())
.then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted)))
.flatten()
}
// a whole output's volume, unmuting it, as the control centre's sliders set it
pub fn set_output_volume(name: &str, volume: u8) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
if sink.muted {
run(&["pactl", "set-sink-mute", name, "0"]);
}
run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]);
sink.volume = volume;
sink.muted = false;
}
pub fn toggle_output_mute(name: &str) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
sink.muted = !sink.muted;
run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]);
}
// where new sound goes; players without an output of their own follow it
pub fn set_default_output(name: &str) {
let mut sound = Self::write();
sound.hold();
sound.default_output = name.to_string();
run(&["pactl", "set-default-sink", name]);
}
pub fn channel(&self, player: &str) -> Option<&Channel> {
self.channels.iter().find(|channel| channel.player == player)
}
@@ -351,10 +411,12 @@ fn poll(known: &mut BTreeSet<String>) {
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
let default_output = amane::output("pactl get-default-sink");
let stale = {
let sound = Sound::read();
!sound.held() && (sound.channels != channels || sound.outputs != outputs)
!sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output)
};
if stale {
@@ -362,6 +424,7 @@ fn poll(known: &mut BTreeSet<String>) {
sound.channels = channels;
sound.outputs = outputs;
sound.default_output = default_output;
}
}
+99 -16
View File
@@ -46,6 +46,20 @@ pub struct Wifi {
pub hotspot: bool,
pub hotspot_name: String,
pub clients: usize,
// the networks in range, the connected one first, then by signal
pub networks: Vec<Network>,
}
#[derive(Clone, PartialEq)]
pub struct Network {
// iwd's object path, which Connect is called on
pub path: String,
pub name: String,
pub secured: bool,
pub known: bool,
pub connected: bool,
pub bars: u8,
}
#[derive(Clone, Copy, PartialEq)]
@@ -136,21 +150,32 @@ fn wifi() -> Wifi {
let connected = station.get("ConnectedNetwork").text();
let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text());
// the connected network's signal, in hundredths of a dBm
let bars = if connected.is_empty() {
0
} else {
let ordered = bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]);
// every network in range as [path, signal in hundredths of a dBm], strongest first
let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing };
let signal = ordered
.list()
.iter()
.find(|entry| entry.list().first().map(|path| path.text()) == Some(connected))
.and_then(|entry| entry.list().get(1))
.map_or(-10_000.0, |signal| signal.number());
let mut networks: Vec<Network> = ordered
.list()
.iter()
.filter_map(|entry| {
let path = entry.list().first()?.text();
let found = objects.get(path)?.get("net.connman.iwd.Network");
let signal = entry.list().get(1).map_or(-10_000.0, |signal| signal.number());
parse::wifi_bars(signal / 100.0)
};
Some(Network {
path: path.to_string(),
name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open",
known: !found.get("KnownNetwork").text().is_empty(),
connected: path == connected,
bars: parse::wifi_bars(signal / 100.0),
})
})
.collect();
// a stable sort keeps the signal order within each group
networks.sort_by_key(|network| !network.connected);
let bars = networks.iter().find(|network| network.connected).map_or(0, |network| network.bars);
let hotspot = access_point.get("Started").bool();
@@ -171,6 +196,7 @@ fn wifi() -> Wifi {
hotspot,
hotspot_name: access_point.get("Name").text().to_string(),
clients,
networks,
}
}
@@ -302,6 +328,33 @@ fn stop_hotspot(device: &str) {
bus.set_property(IWD, device, DEVICE, "Mode", Argument::from("station"));
}
// the Wi-Fi menu: joins a known or open network (a new secured one needs a password, which the menu can't ask for yet)
pub fn connect_network(path: &str) {
Bus::system().call(IWD, path, "net.connman.iwd.Network", "Connect", &[]);
}
pub fn disconnect_wifi() {
let device = Links::read().wifi.device.clone();
if !device.is_empty() {
Bus::system().call(IWD, &device, STATION, "Disconnect", &[]);
}
}
// a fresh list for the open Wi-Fi menu; iwd answers once the scan is done
pub fn scan_wifi() {
let wifi = Links::read().wifi.clone();
if wifi.service && wifi.powered && !wifi.access_point && !wifi.device.is_empty() {
Bus::system().call(IWD, &wifi.device, STATION, "Scan", &[]);
}
}
// back to how the desktop runs without Wi-Fi: iwd not running at all
pub fn stop_wifi_service() {
root(&["rc-service", "iwd", "stop"]);
}
pub fn toggle_vpn(service: &str) {
if !parse::safe_name(service) {
return;
@@ -460,19 +513,49 @@ pub fn set_profile(profile: &str) {
refresh_power();
}
// logind writes the root-owned file for whoever sits at the machine, as amane's Brightness does
/*
* the light's file directly when the user may write it (a udev rule makes it 0666 on the
* desktop), else logind, which only does it for a session with a seat: a shell started
* over ssh has none, which is why logind alone failed
*/
pub fn set_keyboard(level: u32) {
let Some(light) = Power::read().keyboard.clone() else {
return;
};
let arguments = [Argument::from("leds"), Argument::from(light.name.as_str()), Argument::from(level.min(light.max))];
let level = level.min(light.max);
Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments);
let file = Path::new("/sys/class/leds").join(&light.name).join("brightness");
if fs::write(&file, level.to_string()).is_err() {
logind_brightness("leds", &light.name, level);
}
refresh_power();
}
// the laptop screen the same way, at 1% or more; amane's Brightness reads it back within half a second
pub fn set_screen(percent: u8) {
let Some(folder) = fs::read_dir("/sys/class/backlight").ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path()) else {
return;
};
let highest: u64 = read(&folder.join("max_brightness")).parse().unwrap_or(0);
let level = highest * u64::from(percent.clamp(1, 100)) / 100;
if fs::write(folder.join("brightness"), level.to_string()).is_err() {
let name = folder.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
logind_brightness("backlight", &name, level as u32);
}
}
fn logind_brightness(subsystem: &str, name: &str, level: u32) {
let arguments = [Argument::from(subsystem), Argument::from(name), Argument::from(level)];
Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments);
}
// the external gpu
#[derive(Clone, PartialEq)]
+5
View File
@@ -76,6 +76,9 @@ pub struct Theme {
// the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/<name>.ap); empty: none
pub hotspot: String,
// a second Sound slider in the control centre, for the output whose name contains this; empty: none
pub extra_output: String,
}
impl Service for Theme {
@@ -84,6 +87,7 @@ impl Service for Theme {
colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false,
hotspot: String::new(),
extra_output: String::new(),
}
}
@@ -179,6 +183,7 @@ fn apply(theme: &str, config: &str, startup: bool) {
theme.cover_colors = setting("cover_colors") == Some("true");
theme.hotspot = setting("hotspot").unwrap_or("").to_string();
theme.extra_output = setting("extra_output").unwrap_or("").to_string();
// a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value();