control centre: pill shows only running services, struck tiles, menus close in place

The pill drops the icon of a service that isn't running (iwd, bluetoothd, the openvpn
service) and is as wide as what it shows. A tile whose service isn't running is grey
with a grey strike through its icon. Leaving a menu with the pointer fades its content
where it is and shrinks it straight into the pill; the panel no longer grows back to
its full size on the way.
This commit is contained in:
server2
2026-10-10 12:32:44 +02:00
parent 318b2a8443
commit 7bbb23d9ca
2 changed files with 128 additions and 60 deletions
+3 -3
View File
@@ -16,16 +16,16 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn
## Control centre
The pill shows Wi-Fi signal (the hotspot sign while it's on), the VPN lock (accent when connected), Bluetooth (accent with a device connected) and the battery with its percentage (a bolt while charging, red below 15 %). Dim means off or not running.
The pill shows only what runs: Wi-Fi signal while iwd runs (dim while its radio is off; the hotspot sign instead while it's on), the VPN lock only while a VPN connects or is connected (accent once connected), Bluetooth while bluetoothd runs (dim while powered off, accent with a device connected), and the battery with its percentage (a bolt while charging, red below 15 %). The pill is as wide as what it shows.
The panel has:
- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on, grey = the service behind it isn't running. Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu lists the networks in range (connected first, then by signal): click the connected one to disconnect, a saved or open one to join. A new network with a password has to be joined once with `iwctl` (the menu can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu scans while it's open and lists the devices: click to connect, disconnect, or pair (pairing works for devices without a PIN).
- *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`.
- *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup).
- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath, so the two lie on top of each other. The back button returns to the panel; moving the pointer off both closes everything.
- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath, so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first.
- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output.
- **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness).
- **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor.
+120 -52
View File
@@ -10,9 +10,9 @@ use std::thread;
use std::time::{Duration, Instant};
use amane::{
Animation, Audio, Blend, Bluetooth, Brightness, Center, Color, Column, Easing, End, Full, Horizontal, InputArea,
Layer, LayerWindow, Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, SpaceBetween, Stack, Start,
Text, Vertical, Weight, Widget, Zone, children,
Animation, Audio, Blend, Bluetooth, Brightness, Canvas, Cap, Center, Color, Column, Easing, End, Full, Horizontal,
InputArea, Layer, LayerWindow, Line, Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, Shape,
SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
};
use crate::glyph::{Glyph, icon};
@@ -38,7 +38,9 @@ const OUTPUTS: usize = 8;
// the pill and the panel sit this far from the monitor's right edge
const RIGHT: f32 = 12.0;
const PILL_W: f32 = 176.0;
// the pill is as wide as its icons, PILL_PAD on each side, PILL_GAP apart
const PILL_PAD: f32 = 16.0;
const PILL_GAP: f32 = 12.0;
const PILL_HOVER_GROW: f32 = 6.0;
const PANEL_W: f32 = 360.0;
@@ -343,7 +345,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let full_h = menu_h.map_or(layout.height(), |menu_h| mix(layout.height(), menu_h, morph));
let width = mix(PILL_W + PILL_HOVER_GROW * lift, PANEL_W, grow);
let (icons, pill_w) = pill_items(&links, &bluetooth, &power);
let width = mix(pill_w + PILL_HOVER_GROW * lift, PANEL_W, grow);
let height = mix(COMPACT_H, full_h, grow);
let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow);
@@ -367,7 +371,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
veiled(panel(&scene), width, height, radius, reveal * fade, &colors)
}
_ if pill > 0.0 => veiled(compact(&links, &bluetooth, &power), width, height, radius, pill, &colors),
_ if pill > 0.0 => veiled(compact(icons, pill_w), width, height, radius, pill, &colors),
// mid-grow, or under a menu: the empty shape alone
_ => Rectangle::new().width(0.0).height(0.0),
};
@@ -448,11 +452,24 @@ fn open_on(control: &mut Control, monitor: Option<String>) {
control.opened_on = monitor;
}
// a menu that left with the panel (see collapse) stays behind no longer: the panel opens as itself
if !control.menu_up {
control.menu = None;
control.menu_open = menu_opening();
}
control.expanded = true;
control.open.to(1.0);
}
/*
* closes the panel, and an open menu with it: the menu's timeline stays where it is,
* so the panel keeps the menu's size and shrinks from there straight into the pill,
* while menu_view fades the menu's content out in place; nothing grows back to the
* full panel on the way
*/
fn collapse() {
let menu = {
let mut control = Control::write();
control.expanded = false;
@@ -461,9 +478,17 @@ fn collapse() {
// the panel's buttons are about to go, see hovering
control.hover.retain(|spot, _| *spot == Spot::Pill);
drop(control);
let menu = control.menu.filter(|_| control.menu_up);
close_menu();
control.menu_up = false;
control.inside_menu = false;
menu
};
if menu == Some(Menu::Bluetooth) && Bluetooth::read().available() {
Bluetooth::stop_scan();
}
}
/*
@@ -621,45 +646,65 @@ fn status() -> String {
format!("expanded={expanded} menu={menu} wifi={wifi} vpn={vpn} profile={} battery={battery}", power.profile)
}
// the pill: wi-fi (or the hotspot), vpn, bluetooth, battery
// the pill: wi-fi (or the hotspot), vpn and bluetooth while their services run, and the battery
fn compact(links: &Links, bluetooth: &Bluetooth, power: &Power) -> Rectangle {
// the pill's icons and how wide they are together; a stopped service shows nothing here
fn pill_items(links: &Links, bluetooth: &Bluetooth, power: &Power) -> (Vec<Box<dyn Widget>>, f32) {
let theme = theme::now();
let wifi = &links.wifi;
let wifi_icon = if wifi.hotspot {
icon(Glyph::Hotspot, 16.0, theme.accent)
let mut items: Vec<(Box<dyn Widget>, f32)> = Vec::new();
if wifi.hotspot {
items.push((Box::new(icon(Glyph::Hotspot, 16.0, theme.accent)), 16.0));
} else if wifi.service && wifi.powered && !wifi.access_point {
icon(Glyph::Wifi(if wifi.state == "connected" { wifi.bars } else { 0 }), 16.0, theme.text)
} else {
icon(Glyph::Wifi(0), 16.0, theme.dim)
};
let vpn = if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connected) {
theme.accent
} else if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connecting) {
theme.text
} else {
theme.dim
};
let paired = bluetooth.available() && bluetooth.powered();
let bluetooth = if paired && bluetooth.devices().iter().any(|device| device.connected()) {
theme.accent
} else if paired {
theme.text
} else {
theme.dim
};
let mut items: Vec<Box<dyn Widget>> = children![wifi_icon, icon(Glyph::Lock, 14.0, vpn), icon(Glyph::Bluetooth, 15.0, bluetooth)];
if let Some(charge) = &power.battery {
items.push(Box::new(battery(charge)));
items.push((Box::new(icon(Glyph::Wifi(if wifi.state == "connected" { wifi.bars } else { 0 }), 16.0, theme.text)), 16.0));
} else if wifi.service {
// running, radio off
items.push((Box::new(icon(Glyph::Wifi(0), 16.0, theme.dim)), 16.0));
}
Rectangle::new().width(PILL_W).height(COMPACT_H).align_child(Center, Center).child(Row::new(items).gap(12.0).align(Center))
if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connected) {
items.push((Box::new(icon(Glyph::Lock, 14.0, theme.accent)), 14.0));
} else if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connecting) {
items.push((Box::new(icon(Glyph::Lock, 14.0, theme.text)), 14.0));
}
if bluetooth.available() {
let color = if !bluetooth.powered() {
theme.dim
} else if bluetooth.devices().iter().any(|device| device.connected()) {
theme.accent
} else {
theme.text
};
items.push((Box::new(icon(Glyph::Bluetooth, 15.0, color)), 15.0));
}
if let Some(charge) = &power.battery {
items.push((Box::new(battery(charge)), battery_width(charge)));
}
// nothing running and no battery: still something to click
if items.is_empty() {
items.push((Box::new(icon(Glyph::Gauge, 16.0, theme.dim)), 16.0));
}
let width = items.iter().map(|(_, width)| width).sum::<f32>() + PILL_GAP * (items.len() - 1) as f32 + 2.0 * PILL_PAD;
(items.into_iter().map(|(item, _)| item).collect(), width)
}
fn compact(items: Vec<Box<dyn Widget>>, width: f32) -> Rectangle {
Rectangle::new().width(width).height(COMPACT_H).align_child(Center, Center).child(Row::new(items).gap(PILL_GAP).align(Center))
}
// as wide as battery() draws: the bolt, the body and its nub, the percentage
fn battery_width(charge: &Charge) -> f32 {
let bolt = if charge.status == "Charging" { 11.0 + 5.0 } else { 0.0 };
bolt + 24.0 + 1.0 + 1.8 + 5.0 + label_width(&format!("{}%", charge.percent), 11.0, 0.0)
}
// a battery drawn from rectangles, filled to its charge, with a bolt while it charges
@@ -738,10 +783,15 @@ fn note(text: impl Into<String>) -> Text {
enum Look {
On,
Off,
// the service behind it isn't running, or can't be
// nothing to work with: no device, nothing set up
Grey,
// the service behind it isn't running: grey, with a strike through its icon
Stopped,
}
// the strike over a stopped service's icon, neutral grey whatever the theme
const STRIKE: Color = Color::rgba(205, 205, 212, 170);
#[derive(Clone)]
enum Action {
Wifi,
@@ -777,7 +827,7 @@ fn wifi_state(links: &Links) -> State {
let wifi = &links.wifi;
let (look, subtitle) = if !wifi.service {
(Look::Grey, String::from("Service off"))
(Look::Stopped, String::from("Service off"))
} else if wifi.device.is_empty() {
(Look::Grey, String::from("No device"))
} else if wifi.access_point {
@@ -810,9 +860,9 @@ fn bluetooth_state(bluetooth: &Bluetooth, links: &Links) -> State {
} else if bluetooth.available() {
(Look::Off, String::from("Off"), Some(Action::Bluetooth))
} else if links.bluetooth_service {
(Look::Grey, String::from("Service off"), Some(Action::Bluetooth))
(Look::Stopped, String::from("Service off"), Some(Action::Bluetooth))
} else {
(Look::Grey, String::from("Not installed"), None)
(Look::Stopped, String::from("Not installed"), None)
};
State { look, glyph: Glyph::Bluetooth, subtitle, action }
@@ -827,7 +877,7 @@ fn vpn_state(links: &Links) -> State {
let (look, subtitle) = match vpn.state {
VpnState::Connected => (Look::On, vpn.name().to_string()),
VpnState::Connecting => (Look::On, String::from("Connecting…")),
VpnState::Stopped => (Look::Off, vpn.name().to_string()),
VpnState::Stopped => (Look::Stopped, vpn.name().to_string()),
};
State { look, glyph: Glyph::Lock, subtitle, action: Some(Action::Vpn(vpn.service.clone())) }
@@ -837,7 +887,7 @@ fn hotspot_state(scene: &Scene) -> State {
let wifi = &scene.links.wifi;
let (look, subtitle, action) = if !wifi.service {
(Look::Grey, String::from("Start Wi-Fi"), Some(Action::Hotspot))
(Look::Stopped, String::from("Start Wi-Fi"), Some(Action::Hotspot))
} else if wifi.device.is_empty() {
(Look::Grey, String::from("No device"), None)
} else if wifi.hotspot {
@@ -866,13 +916,24 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu:
let (round, mark) = match state.look {
Look::On => (theme.accent, theme.on_accent),
Look::Off => (CHIP_OPEN, theme.text),
Look::Grey => (CHIP, theme.dim),
Look::Grey | Look::Stopped => (CHIP, theme.dim),
};
let subtitle = if busy { String::from("One moment…") } else { state.subtitle.clone() };
let words_w = TILE_W - 12.0 - 10.0 - ROUND - 10.0;
let mark = Rectangle::new().width(ROUND).height(ROUND).align_child(Center, Center).child(icon(state.glyph, 16.0, mark));
// a stopped service's icon is struck through, corner to corner, like a slashed sign
let mark = if state.look == Look::Stopped {
let strike = Canvas::new().width(ROUND).height(ROUND).shapes(shapes![Line::new().from(9.0, 9.0).to(ROUND - 9.0, ROUND - 9.0).stroke(1.8, STRIKE).cap(Cap::Round)]);
Rectangle::new().width(ROUND).height(ROUND).child(Stack::new(children![mark, strike]).width(ROUND).height(ROUND))
} else {
mark
};
let tile = Rectangle::new()
.width(TILE_W)
.height(TILE_H)
@@ -884,9 +945,9 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu:
.on_hover(hovering(Spot::Tile(which)))
.child(
Row::new(children![
Rectangle::new().width(ROUND).height(ROUND).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).align_child(Center, Center).child(icon(state.glyph, 16.0, mark)),
Rectangle::new().width(ROUND).height(ROUND).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).child(mark),
Column::new(children![
Text::new(title).size(12.0).weight(Weight::Bold).color(if state.look == Look::Grey { theme.dim } else { theme.text }).elide(),
Text::new(title).size(12.0).weight(Weight::Bold).color(if matches!(state.look, Look::Grey | Look::Stopped) { theme.dim } else { theme.text }).elide(),
Text::new(subtitle).size(10.5).color(theme.dim).elide(),
])
.gap(3.0)
@@ -1348,8 +1409,12 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let on_here = control.opened_on.as_deref().is_none_or(|name| name == here);
let timeline = control.menu_open.value();
// the whole panel is closing with this menu on it: the menu only fades its content, then leaves the shrinking to the panel
let leaving = !control.menu_up && !control.expanded;
let (_, _, panel_reveal) = phases(if on_here { control.open.value() } else { 0.0 });
// never hidden (see the island's view): an empty click-through window instead
let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0) else {
let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0 && (!leaving || panel_reveal > 0.0)) else {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
};
@@ -1362,7 +1427,10 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let menu_h = menu_height(&content);
let (grow, _, reveal) = phases(timeline);
let (grow, _, settled) = phases(timeline);
// leaving: the content fades with the panel's own timeline, the shape stays put on the panel
let reveal = if leaving { settled * panel_reveal } else { settled };
let (x0, y0, w0, h0) = origin;
let (x, y) = (mix(x0, 0.0, grow), mix(y0, 0.0, grow));
@@ -1390,7 +1458,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.fill(backdrop(&colors, 255))
.border(1.0, theme::now().outline)
// a shadow only while it grows: settled, it lies on the panel, which has its own glow
.shadow(Shadow::drop("#000000").opacity(0.45 * grow * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0))
.shadow(Shadow::drop("#000000").opacity(0.45 * grow * (1.0 - settled)).blur(24.0).offset(0.0, 10.0))
.align_child(Center, Start)
.translate(x, y)
.on_hover(|inside| {