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:
+787
-43
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user