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
+125 -57
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,22 +452,43 @@ 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 mut control = Control::write();
let menu = {
let mut control = Control::write();
control.expanded = false;
control.open.to(0.0);
control.expanded = false;
control.open.to(0.0);
// the panel's buttons are about to go, see hovering
control.hover.retain(|spot, _| *spot == Spot::Pill);
// 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| {