Control centre: popups via iwctl/bluetoothctl, one-shape menus, search rings, Ethernet and link speeds, auto-hiding bar

- prompt.rs: popup for Wi-Fi passwords, Bluetooth PINs/passkeys/codes, saved
  passwords (Copy through wl-copy on stdin) and failure reasons; joins through
  iwctl, pairs through a bluetoothctl session; only popups with a field take
  the keyboard (an exclusive grab let stray keystrokes close failure popups)
- menus: the panel is the only shape; the menu window fades its content in
  once the panel has taken the menu's size
- maps: clicking the bubble searches again (iwctl scan, bluetoothctl scan)
  with rings running out from the click
- Ethernet tile and pill icon from sysfs; link speed badges (Ethernet speed,
  Wi-Fi RxBitrate from iwd diagnostics, at most once a minute)
- edge.rs: bar hidden until the pointer rests on the top edge; nothing is
  read or drawn while hidden
- IPC: control wifi join|password|search, bluetooth pair|connect|search,
  prompt answer|cancel, status prompt=
This commit is contained in:
server2
2026-10-13 02:59:22 +02:00
parent afe963dfea
commit b14df3a435
11 changed files with 1550 additions and 174 deletions
+17 -9
View File
@@ -12,20 +12,23 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace takes the accent colour, the one shown on another monitor a softer accent, empty ones dim. Click one to switch to it.
- **Control centre:** top right on every monitor, a pill with Wi-Fi (or the hotspot), VPN, Bluetooth and the battery. Click it and it opens, in the island's way, into a panel like macOS's Control Center. See [Control centre](#control-centre).
- **Control centre:** top right on every monitor, a pill with Ethernet, Wi-Fi (or the hotspot), VPN, Bluetooth and the battery. Click it and it opens, in the island's way, into a panel like macOS's Control Center. See [Control centre](#control-centre).
- **Hidden until wanted:** the island, the workspace pill and the control centre's pill stay out of sight until the pointer rests on a monitor's top edge for 0.3 s; then they slide down on that monitor, and slide away 0.7 s after the pointer has left them and the edge. An open card or panel stays. A one-pixel strip along each monitor's top notices the pointer (nothing polls); it's an Overlay layer like the bar, so this works over fullscreen windows too, at the cost of their top pixel row. While hidden the bar draws nothing at all, a playing player included (`src/edge.rs`).
## Control centre
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 pill shows only what runs: the Ethernet socket while a cable is connected, 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 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 is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it, the connected one to disconnect. A new network with a password has to be joined once with `iwctl` (the map 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 is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (pairing works for devices without a PIN).
- **Tiles:** Wi-Fi, Bluetooth, VPN, Hotspot, and Ethernet across the panel when the machine has a wired card. 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 is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it (`iwctl station <dev> connect`), the connected one to disconnect; a new network asks for its password in a popup first (802.1x networks still need `iwctl`). If joining fails, the popup says why, in iwctl's words. Right-click a saved network for its saved password (read with `doas -n cat /var/lib/iwd/<profile>.psk`), which Copy (or a click on it) puts on the clipboard through `wl-copy`. Click the bubble in the middle to search again: rings run out from it while iwd scans. The tile and the connected network show the receive link rate (iwd's diagnostics, asked at most once a minute). "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 is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (then trust and connect). Pairing and connecting go through a `bluetoothctl` session with its own agent: a PIN or passkey it needs, a code to confirm or to type on the device are asked in a popup, and a failure shows why. Click the bubble to search for devices (`bluetoothctl --timeout 15 scan on`).
- *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 (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), 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.
- *Ethernet*: every real wired card in `/sys/class/net` (a device behind it, not wireless): connected or no cable, and the link speed it negotiated (`speed`, as 1 Gb/s). Shows only.
- **Popups:** questions (a password, PIN or passkey), a code to confirm, a saved password or why something failed, in a small card over the menu. Only a popup with a field takes the keyboard (Enter answers, Escape cancels); the others leave keys to other windows until clicked.
- **Menus:** one shape the whole way: the panel fades its content out and takes the menu's size (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), then the menu's content fades in over it from a window of its own (so the maps' animation redraws only that window). 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.
@@ -36,7 +39,10 @@ Changes that need root (starting services, the performance mode) run as `doas -n
```sh
amane ipc call control toggle # also: open, close
amane ipc call control menu wifi # also: bluetooth, vpn, sound, close
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%>
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%> prompt=<what the popup shows|none>
amane ipc call control wifi join <SSID> # as a click on it in the map; also: wifi password <SSID>, wifi search
amane ipc call control bluetooth pair <ADDRESS> # also: bluetooth connect <ADDRESS>, bluetooth search
amane ipc call control prompt answer <text> # answers the popup; also: prompt cancel
```
### Hotspot setup
@@ -118,7 +124,9 @@ src/art.rs cover download (curl) and the palette file
src/workspaces.rs the workspace pill, top left on every monitor
src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC
src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it
src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU
src/prompt.rs the control centre's popup: passwords, PINs, codes, saved passwords, why a connection failed; joins through iwctl, pairs through bluetoothctl
src/edge.rs hides the bar until the pointer rests on a monitor's top edge
src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
src/parse.rs pure text parsing with unit tests, no amane dependency
@@ -136,7 +144,7 @@ On the desktop that runs the shell:
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
- `cava`, `curl`
- the Fira Code font
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), optionally `bluez`, and a doas rule without a password for `rc-service` and `sh` (see [Control centre](#control-centre))
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre))
## Install and run
+253 -125
View File
@@ -10,15 +10,17 @@ use std::thread;
use std::time::{Duration, Instant};
use amane::{
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,
Animation, Audio, Blend, Bluetooth, Brightness, Button, 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::edge::{self, Edge, HIDE_H};
use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled};
use crate::map::{self, AREA_H, Link, MAP_W, NODES, Node};
use crate::map::{self, AREA_H, Centre, Link, MAP_W, NODES, Node};
use crate::parse;
use crate::prompt;
use crate::sound::Sound;
use crate::system::{self, Charge, Gpu, Links, Power, VpnState};
use crate::theme::{self, Theme};
@@ -37,7 +39,7 @@ const OUTPUT_H: f32 = 22.0;
const OUTPUTS: usize = 8;
// the pill and the panel sit this far from the monitor's right edge
const RIGHT: f32 = 12.0;
pub const RIGHT: f32 = 12.0;
// both windows are as wide as a map menu, the widest the panel becomes
const WINDOW_W: f32 = RIGHT + MAP_W + SHADOW;
@@ -85,6 +87,7 @@ pub enum Tile {
Bluetooth,
Vpn,
Hotspot,
Ethernet,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
@@ -119,6 +122,8 @@ enum Spot {
Switch,
Row(usize),
Footer,
// this desktop's bubble in a map
Bubble,
OutputMute(u32),
}
@@ -148,6 +153,9 @@ pub struct Control {
// a network or device just clicked to connect (iwd path or bluez path): its line runs out until it's connected
pending: Option<(String, Instant)>,
// when the bubble in a map was clicked to search again: rings run out from it
scan_at: Option<Instant>,
}
impl Service for Control {
@@ -166,6 +174,7 @@ impl Service for Control {
inside_panel: false,
inside_menu: false,
pending: None,
scan_at: None,
}
}
@@ -178,6 +187,11 @@ impl Control {
self.expanded
}
// the monitor the panel opened on; none: every monitor, without a workspace list
pub fn opened_on(&self) -> Option<&str> {
self.opened_on.as_deref()
}
fn lit(&self, spot: Spot) -> f32 {
self.hover.get(&spot).map_or(0.0, Animation::value)
}
@@ -197,6 +211,47 @@ impl Control {
fn pending(&self, id: &str) -> bool {
self.pending.as_ref().is_some_and(|(pending, at)| pending == id && at.elapsed() < Duration::from_secs(20))
}
/*
* seconds since a map's search began, for its rings: since the click on the bubble
* (shown for a few rings even if the search is quick), else while the daemon says it
* searches on its own
*/
fn searching(&self, daemon: bool) -> Option<f32> {
let clicked = self.scan_at.map(|at| at.elapsed().as_secs_f32());
match clicked {
Some(since) if since < map::RIPPLE_S || daemon => Some(since),
_ => daemon.then(map::clock),
}
}
}
// the bubble in a map: a click searches again, through iwctl or bluetoothctl, with rings running out; nothing while the radio is off
fn search_again(menu: Menu) {
let can = match menu {
Menu::Wifi => {
let links = Links::read();
links.wifi.service && links.wifi.powered && !links.wifi.access_point
}
_ => {
let bluetooth = Bluetooth::read();
bluetooth.available() && bluetooth.powered()
}
};
if !can {
return;
}
Control::write().scan_at = Some(Instant::now());
match menu {
Menu::Wifi => later(system::scan_wifi, None),
_ => later(system::scan_bluetooth, None),
}
}
// one spot lit at a time: amane matches hover targets by their place in the frame, so a vanished one never hears the pointer leave
@@ -223,6 +278,8 @@ fn hovering(spot: Spot) -> impl Fn(bool) + 'static {
// what decides the panel's height: the blocks this machine has
#[derive(Clone, Copy)]
struct Layout {
// a wired card: a tile across the panel under the four
ethernet: bool,
extra_output: bool,
screen: bool,
keyboard: bool,
@@ -232,7 +289,7 @@ struct Layout {
}
impl Layout {
const LARGEST: Layout = Layout { extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) };
const LARGEST: Layout = Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) };
fn sound(&self) -> f32 {
let extra: &[f32] = if self.extra_output { &[TITLE_H, SLIDER_H] } else { &[] };
@@ -258,8 +315,9 @@ impl Layout {
fn height(&self) -> f32 {
let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| GAP + height).sum();
let tile_rows = if self.ethernet { 3.0 } else { 2.0 };
2.0 * PAD + 2.0 * TILE_H + GAP + modules
2.0 * PAD + tile_rows * TILE_H + (tile_rows - 1.0) * GAP + modules
}
}
@@ -289,19 +347,15 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let control = Control::read();
let links = Links::read();
let power = Power::read();
let bluetooth = Bluetooth::read();
let here = monitor.name.as_str();
// the other monitors keep their pill while the panel is open here
let opened_here = control.opened_on.as_deref().is_none_or(|name| name == here);
let (grow, pill, reveal) = phases(if opened_here { control.open.value() } else { 0.0 });
let hovered_here = control.pointer_on.as_deref() == Some(here);
let lift = if hovered_here { control.lit(Spot::Pill) } else { 0.0 } * (1.0 - grow);
// hidden until the pointer rests on the top edge (edge.rs); an open panel keeps it in
let open = if opened_here { control.open.value() } else { 0.0 };
let slide = edge::hidden_by(Edge::read().shown(here), open, HIDE_H);
// as wide as the widest thing it can become, a map menu; the shape grows leftwards from the corner
let window_w = WINDOW_W;
@@ -316,6 +370,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.space(Zone::Ignore)
.namespace("magic-control");
// out of sight: empty and click-through, and nothing else read, so status changes don't redraw it
if slide <= -HIDE_H {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
}
let links = Links::read();
let power = Power::read();
let bluetooth = Bluetooth::read();
let (grow, pill, reveal) = phases(open);
let hovered_here = control.pointer_on.as_deref() == Some(here);
let lift = if hovered_here { control.lit(Spot::Pill) } else { 0.0 } * (1.0 - grow);
// what only the panel shows is read only while it shows, so a volume change doesn't redraw every pill
let open = grow > 0.0;
@@ -331,6 +399,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
};
let layout = Layout {
ethernet: !links.wired.is_empty(),
extra_output: extra_output.is_some(),
screen: screen.as_deref().is_some_and(Brightness::present),
keyboard: power.keyboard.is_some(),
@@ -349,7 +418,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let menu_size = match (control.menu.filter(|_| menu_t > 0.0), &sound) {
(Some(menu), Some(sound)) => {
let content = menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0;
let content = menu_content(menu, &control, &links, &bluetooth, sound);
Some((content.width, menu_height(&content)))
}
@@ -417,6 +486,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
control.inside_panel = inside;
}
edge::over_bar(inside);
hovering(Spot::Pill)(inside);
// leaving a pill on another monitor leaves the open panel alone
@@ -444,6 +514,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 })
.align_child(End, Start)
.translate(0.0, slide)
.child(shape),
)
}
@@ -524,12 +595,18 @@ fn collapse_soon() {
!control.inside_panel && !control.inside_menu
};
if gone {
// a question or reason in the popup keeps everything open: the pointer went to answer it
if gone && !prompt::showing() {
collapse();
}
});
}
// a connection attempt that was called off or failed (see prompt.rs): its line stops running
pub fn clear_pending() {
Control::write().pending = None;
}
fn menu_opening() -> Animation {
Animation::new(0.0).duration(Duration::from_millis(MENU_MS)).easing(Easing::Linear)
}
@@ -613,8 +690,24 @@ pub fn command(arguments: &[String]) -> String {
open_menu(menu);
}
["prompt", rest @ ..] => return prompt::command(rest),
// what a click on the map does, for keybinds and tests: a left-click joins, a right-click shows the saved password
["wifi", "join", ssid] => {
let needs_password = Links::read().wifi.networks.iter().find(|network| network.name == *ssid).is_some_and(|network| network.secured && !network.known && !network.enterprise);
prompt::join(ssid, needs_password);
}
["wifi", "password", ssid] => prompt::reveal(ssid),
// what a click on a map's bubble does
["wifi", "search"] => search_again(Menu::Wifi),
["bluetooth", "search"] => search_again(Menu::Bluetooth),
["bluetooth", action @ ("pair" | "connect"), address] => {
let name = Bluetooth::read().devices().iter().find(|device| device.address() == *address).map_or(address.to_string(), |device| device.name().to_string());
prompt::bluetooth(address, &name, *action == "pair");
}
["status"] => return status(),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|status"),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|wifi <join|password> SSID|wifi search|bluetooth <pair|connect> ADDRESS|bluetooth search|status"),
}
String::from("ok")
@@ -661,7 +754,7 @@ fn status() -> String {
let power = Power::read();
let battery = power.battery.as_ref().map_or(String::from("none"), |charge| format!("{}%", charge.percent));
format!("expanded={expanded} menu={menu} wifi={wifi} vpn={vpn} profile={} battery={battery}", power.profile)
format!("expanded={expanded} menu={menu} wifi={wifi} vpn={vpn} profile={} battery={battery} prompt={}", power.profile, prompt::status())
}
// the pill: wi-fi (or the hotspot), vpn and bluetooth while their services run, and the battery
@@ -673,6 +766,10 @@ fn pill_items(links: &Links, bluetooth: &Bluetooth, power: &Power) -> (Vec<Box<d
let mut items: Vec<(Box<dyn Widget>, f32)> = Vec::new();
if links.wired.iter().any(|card| card.connected) {
items.push((Box::new(icon(Glyph::Ethernet, 15.0, theme.text)), 15.0));
}
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 {
@@ -823,6 +920,9 @@ struct State {
glyph: Glyph,
subtitle: String,
action: Option<Action>,
// a link speed, small at the right of the title
badge: Option<String>,
}
fn tiles(scene: &Scene) -> Column {
@@ -830,15 +930,20 @@ fn tiles(scene: &Scene) -> Column {
let control = scene.control;
// Wi-Fi, Bluetooth and VPN open their menu; the hotspot just switches
Column::new(children![
// Wi-Fi, Bluetooth and VPN open their menu; the hotspot just switches; a wired link only shows, across the panel
let mut rows: Vec<Box<dyn Widget>> = children![
row(
tile(control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links), Some(Menu::Wifi)),
tile(control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene.bluetooth, scene.links), Some(Menu::Bluetooth)),
tile(control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links), Some(Menu::Wifi), TILE_W),
tile(control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene.bluetooth, scene.links), Some(Menu::Bluetooth), TILE_W),
),
row(tile(control, Tile::Vpn, "VPN", vpn_state(scene.links), Some(Menu::Vpn)), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None)),
])
.gap(GAP)
row(tile(control, Tile::Vpn, "VPN", vpn_state(scene.links), Some(Menu::Vpn), TILE_W), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None, TILE_W)),
];
if scene.layout.ethernet {
rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, INNER_W)));
}
Column::new(rows).gap(GAP)
}
fn wifi_state(links: &Links) -> State {
@@ -864,8 +969,22 @@ fn wifi_state(links: &Links) -> State {
// grey without a device: there's nothing to start or switch
let action = (!wifi.service || !wifi.device.is_empty()).then_some(Action::Wifi);
let badge = wifi.rate.map(parse::speed_words);
State { look, glyph: Glyph::Wifi(3), subtitle, action }
State { look, glyph: Glyph::Wifi(3), subtitle, action, badge }
}
// the connected card, else the first; the panel shows this tile only with one
fn ethernet_state(links: &Links) -> State {
let card = links.wired.iter().find(|card| card.connected).or(links.wired.first());
let (look, subtitle) = match card {
Some(card) if card.connected => (Look::On, format!("Connected · {}", card.name)),
Some(card) => (Look::Off, format!("No cable · {}", card.name)),
None => (Look::Grey, String::new()),
};
State { look, glyph: Glyph::Ethernet, subtitle, action: None, badge: card.and_then(|card| card.speed).map(parse::speed_words) }
}
fn bluetooth_state(bluetooth: &Bluetooth, links: &Links) -> State {
@@ -883,13 +1002,13 @@ fn bluetooth_state(bluetooth: &Bluetooth, links: &Links) -> State {
(Look::Stopped, String::from("Not installed"), None)
};
State { look, glyph: Glyph::Bluetooth, subtitle, action }
State { look, glyph: Glyph::Bluetooth, subtitle, action, badge: None }
}
fn vpn_state(links: &Links) -> State {
// the one that's up, else the first
let Some(vpn) = links.vpns.iter().find(|vpn| vpn.state != VpnState::Stopped).or(links.vpns.first()) else {
return State { look: Look::Grey, glyph: Glyph::Lock, subtitle: String::from("None set up"), action: None };
return State { look: Look::Grey, glyph: Glyph::Lock, subtitle: String::from("None set up"), action: None, badge: None };
};
let (look, subtitle) = match vpn.state {
@@ -898,7 +1017,7 @@ fn vpn_state(links: &Links) -> State {
VpnState::Stopped => (Look::Stopped, vpn.name().to_string()),
};
State { look, glyph: Glyph::Lock, subtitle, action: Some(Action::Vpn(vpn.service.clone())) }
State { look, glyph: Glyph::Lock, subtitle, action: Some(Action::Vpn(vpn.service.clone())), badge: None }
}
fn hotspot_state(scene: &Scene) -> State {
@@ -922,10 +1041,10 @@ fn hotspot_state(scene: &Scene) -> State {
(Look::Off, scene.hotspot.to_string(), Some(Action::Hotspot))
};
State { look, glyph: Glyph::Hotspot, subtitle, action }
State { look, glyph: Glyph::Hotspot, subtitle, action, badge: None }
}
fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu: Option<Menu>) -> Rectangle {
fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu: Option<Menu>, width: f32) -> Rectangle {
let theme = theme::now();
let lit = control.lit(Spot::Tile(which));
@@ -939,7 +1058,7 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu:
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 words_w = width - 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));
@@ -952,8 +1071,17 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu:
mark
};
// the title, with a link speed at its right when there is one
let title_color = if matches!(state.look, Look::Grey | Look::Stopped) { theme.dim } else { theme.text };
let title = Text::new(title).size(12.0).weight(Weight::Bold).color(title_color).elide();
let title: Box<dyn Widget> = match &state.badge {
Some(badge) => Box::new(Row::new(children![title, Text::new(badge.as_str()).size(10.0).color(theme.accent)]).justify(SpaceBetween).align(Center).width(words_w)),
None => Box::new(title),
};
let tile = Rectangle::new()
.width(TILE_W)
.width(width)
.height(TILE_H)
.radius(MODULE_RADIUS)
.fill(Color::blend(CHIP, CHIP_HOVER, lit))
@@ -964,12 +1092,7 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu:
.child(
Row::new(children![
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 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)
.width(words_w),
Column::new(vec![title, Box::new(Text::new(subtitle).size(10.5).color(theme.dim).elide())]).gap(3.0).width(words_w),
])
.gap(10.0)
.align(Center),
@@ -1438,66 +1561,44 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let links = Links::read();
let bluetooth = Bluetooth::read();
let sound = Sound::read();
let extra = sound.output_named(&Theme::read().extra_output).is_some();
let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra);
let content = menu_content(menu, &control, &links, &bluetooth, &sound);
let menu_w = content.width;
let menu_h = menu_height(&content);
let window = window(RIGHT + menu_w + SHADOW, TOP_GAP + menu_h + SHADOW);
/*
* one shape the whole way, the panel's: it fades its own content and takes the menu's
* size (see view), and only then does this window fade the menu's content in over it,
* with no shape of its own. leaving the menu runs the same backwards; with the whole
* panel closing, the content fades with the panel's own timeline
*/
let (_, _, settled) = phases(timeline);
let reveal = if leaving { settled * panel_reveal } else { settled };
if reveal <= 0.0 {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
}
// the maps float and wobble all the time: frames for as long as one shows
if matches!(menu, Menu::Wifi | Menu::Bluetooth) {
amane::request_frame();
}
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 };
// from the tile, measured from the panel's top left, which sits at the right of the menu-wide box
let (x0, y0, w0, h0) = origin;
let x0 = x0 + menu_w - PANEL_W;
// only as big as this menu and the tile it grows from: every frame redraws the whole window, and maps redraw all the time
let box_h = menu_h.max(y0 + h0);
let window_w = RIGHT + menu_w + SHADOW;
let window = window(window_w, TOP_GAP + box_h + SHADOW);
// to the menu's own size, against the same right edge as the panel
let (x, y) = (mix(x0, 0.0, grow), mix(y0, 0.0, grow));
let (width, height) = (mix(w0, menu_w, grow), mix(h0, menu_h, grow));
let radius = mix(MODULE_RADIUS, PANEL_RADIUS, grow);
let colors = theme::now().surfaces();
let face = if reveal > 0.0 {
let page = Rectangle::new()
.width(menu_w)
.height(menu_h)
.padding(MENU_PAD)
.child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * MENU_PAD), content.body]).gap(GAP));
veiled(page, width, height, radius, reveal, &colors)
} else {
Rectangle::new().width(0.0).height(0.0)
};
// a shadow only while it grows: settled, it lies on the panel, which has its own glow; a blur costs every frame, even at 0
let shadow = 0.45 * grow * (1.0 - settled);
let page = Rectangle::new()
.width(menu_w)
.height(menu_h)
.padding(MENU_PAD)
.child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * MENU_PAD), content.body]).gap(GAP));
// the veil is the panel's own backdrop, so the fade shows nothing but the content
let shape = Rectangle::new()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(&colors, 255))
.border(1.0, theme::now().outline);
let shape = if shadow > 0.01 { shape.shadow(Shadow::drop("#000000").opacity(shadow).blur(24.0).offset(0.0, 10.0)) } else { shape };
let shape = shape
.align_child(Center, Start)
.translate(x, y)
.width(menu_w)
.height(menu_h)
.on_hover(|inside| {
Control::write().inside_menu = inside;
@@ -1505,14 +1606,9 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
collapse_soon();
}
})
.child(face);
.child(veiled(page, menu_w, menu_h, PANEL_RADIUS, reveal, &colors));
let region = InputArea {
x: (window_w - RIGHT - menu_w + x).floor() as i32,
y: (TOP_GAP + y).floor() as i32,
width: width.ceil() as i32,
height: height.ceil() as i32,
};
let region = InputArea { x: 0, y: TOP_GAP as i32, width: menu_w.ceil() as i32, height: menu_h.ceil() as i32 };
window.input_region(vec![region]).child(
Rectangle::new()
@@ -1520,7 +1616,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 })
.align_child(End, Start)
.child(Rectangle::new().width(menu_w).height(box_h).align_child(Start, Start).child(shape)),
.child(shape),
)
}
@@ -1532,20 +1628,15 @@ fn menu_back() {
}
/*
* a menu's content and the tile or block it grows out of (x, y, width, height from the
* panel's top left). both windows call it, the panel for the height it takes; every
* a menu's content. both windows call it, the panel for the size it takes; every
* Service is passed in, since a view reading one twice can deadlock against a writer
*/
fn menu_content(menu: Menu, control: &Control, links: &Links, bluetooth: &Bluetooth, sound: &Sound, extra_output: bool) -> (MenuContent, (f32, f32, f32, f32)) {
fn menu_content(menu: Menu, control: &Control, links: &Links, bluetooth: &Bluetooth, sound: &Sound) -> MenuContent {
match menu {
Menu::Wifi => (wifi_menu(control, links), (PAD, PAD, TILE_W, TILE_H)),
Menu::Bluetooth => (bluetooth_menu(control, bluetooth, links), (PAD + TILE_W + GAP, PAD, TILE_W, TILE_H)),
Menu::Vpn => (vpn_menu(control, links), (PAD, PAD + TILE_H + GAP, TILE_W, TILE_H)),
Menu::Sound => {
let block = Layout { extra_output, ..Layout::LARGEST }.sound();
(sound_menu(control, sound), (PAD, PAD + 2.0 * (TILE_H + GAP), INNER_W, block))
}
Menu::Wifi => wifi_menu(control, links),
Menu::Bluetooth => bluetooth_menu(control, bluetooth, links),
Menu::Vpn => vpn_menu(control, links),
Menu::Sound => sound_menu(control, sound),
}
}
@@ -1750,36 +1841,48 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent {
};
let detail = match link {
Link::Connected => "Connected",
Link::Connecting => "Connecting…",
Link::Idle if network.known => "Saved",
Link::Idle if !network.secured => "Open",
Link::Idle => "New · join with iwctl",
Link::Connected => match wifi.rate {
Some(rate) => format!("{} · right-click: password", parse::speed_words(rate)),
None => String::from("Connected · right-click: password"),
},
Link::Connecting => String::from("Connecting…"),
Link::Idle if network.known => String::from("Saved · right-click: password"),
Link::Idle if !network.secured => String::from("Open"),
Link::Idle if network.enterprise => String::from("Enterprise · use iwctl"),
Link::Idle => String::from("New · click to join"),
};
let joinable = network.known || !network.secured;
let path = network.path.clone();
let ssid = network.name.clone();
let (known, secured, enterprise) = (network.known, network.secured, network.enterprise);
let on_click: Option<Box<dyn Fn()>> = match link {
Link::Connected => Some(Box::new(|| {
Control::write().pending = None;
// left: join or leave (iwctl asks nothing for saved or open ones; a new one asks for its password in the popup); right: the saved password
let on_click: Option<Box<dyn Fn(Button)>> = match link {
_ if enterprise && !known => None,
Link::Connecting => None,
Link::Connected => Some(Box::new(move |button| match button {
Button::Right => prompt::reveal(&ssid),
_ => {
Control::write().pending = None;
later(system::disconnect_wifi, None);
later(system::disconnect_wifi, None);
}
})),
Link::Idle if joinable => Some(Box::new(move || {
Control::write().pending = Some((path.clone(), Instant::now()));
Link::Idle => Some(Box::new(move |button| match button {
Button::Right if known => prompt::reveal(&ssid),
Button::Right => {}
_ => {
Control::write().pending = Some((path.clone(), Instant::now()));
let path = path.clone();
later(move || system::connect_network(&path), None);
prompt::join(&ssid, secured && !known);
}
})),
_ => None,
};
Node {
glyph: Glyph::Wifi(network.bars),
name: network.name.clone(),
detail: String::from(detail),
detail,
nearness: f32::from(network.bars.saturating_sub(1)) / 2.0,
link,
lit: control.lit(Spot::Row(index)),
@@ -1791,7 +1894,16 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent {
let mut body = Body::new(GAP);
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, &links.host, nodes, wifi.scanning, note), AREA_H);
let centre = Centre {
host: &links.host,
note,
searching: control.searching(wifi.scanning),
lit: control.lit(Spot::Bubble),
on_hover: Box::new(hovering(Spot::Bubble)),
on_click: Box::new(|| search_again(Menu::Wifi)),
};
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, centre, nodes), AREA_H);
if wifi.service {
body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0);
@@ -1855,9 +1967,16 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me
};
let path = String::from(device.path());
let address = String::from(device.address());
let name = device.name().to_string();
let (connected, paired) = (device.connected(), device.paired());
let on_click: Box<dyn Fn()> = Box::new(move || {
// through bluetoothctl, whose agent asks in the popup and which says why it failed; leaving needs nothing of that
let on_click: Box<dyn Fn(Button)> = Box::new(move |button| {
if button != Button::Left {
return;
}
if connected {
Control::write().pending = None;
@@ -1865,7 +1984,7 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me
} else {
Control::write().pending = Some((path.clone(), Instant::now()));
if paired { Bluetooth::connect(&path) } else { Bluetooth::pair(&path) }
prompt::bluetooth(&address, &name, !paired);
}
});
@@ -1884,7 +2003,16 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me
let mut body = Body::new(GAP);
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, &links.host, nodes, bluetooth.available() && bluetooth.scanning(), note), AREA_H);
let centre = Centre {
host: &links.host,
note,
searching: control.searching(bluetooth.available() && bluetooth.scanning()),
lit: control.lit(Spot::Bubble),
on_hover: Box::new(hovering(Spot::Bubble)),
on_click: Box::new(|| search_again(Menu::Bluetooth)),
};
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, centre, nodes), AREA_H);
body.into_content("Bluetooth", right, MAP_W)
}
+142
View File
@@ -0,0 +1,142 @@
/*
* the bar hides: the island, the workspaces and the control centre's pill stay out of
* sight until the pointer rests on a monitor's top edge for DWELL_MS, then slide down on
* that monitor. a one-pixel strip along every monitor's top hears the pointer arrive, so
* nothing polls; it's an Overlay layer like the bar, so it works over fullscreen windows
* too (and takes their top pixel row). the bar slides away once the pointer has been off
* it and the edge for HIDE_MS; an open card or panel keeps its own part out (see their views)
*/
use std::collections::HashMap;
use std::thread;
use std::time::Duration;
use amane::{Animation, Full, Layer, LayerWindow, Monitor, Parent, Rectangle, Service, Vertical, Zone};
use crate::island::{COMPACT_H, SHADOW, TOP_GAP};
const DWELL_MS: u64 = 300;
const HIDE_MS: u64 = 700;
const SLIDE_MS: u64 = 220;
// how far a pill slides up to be out of sight, glow and all
pub const HIDE_H: f32 = TOP_GAP + COMPACT_H + SHADOW;
pub struct Edge {
// how far the bar is in on each monitor, 0 to 1
shown: HashMap<String, Animation>,
// the monitor whose top edge the pointer rests on, and whether it's over a part of the bar
at_edge: Option<String>,
over_bar: bool,
// bumped by every arrival and departure, so a timer an earlier one started does nothing
moves: u32,
}
impl Service for Edge {
fn new() -> Self {
Self { shown: HashMap::new(), at_edge: None, over_bar: false, moves: 0 }
}
// changes only through input
fn listen() {}
}
impl Edge {
pub fn shown(&self, monitor: &str) -> f32 {
self.shown.get(monitor).map_or(0.0, Animation::value)
}
}
// the bar's parts call this from their on_hover
pub fn over_bar(inside: bool) {
let moves = {
let mut edge = Edge::write();
edge.over_bar = inside;
edge.moves += 1;
edge.moves
};
if !inside {
hide_soon(moves);
}
}
fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
move |inside| {
let moves = {
let mut edge = Edge::write();
edge.at_edge = inside.then(|| monitor.clone());
edge.moves += 1;
edge.moves
};
if !inside {
hide_soon(moves);
return;
}
let monitor = monitor.clone();
// still resting there after the dwell: this monitor's bar comes, any other goes
thread::spawn(move || {
thread::sleep(Duration::from_millis(DWELL_MS));
let mut edge = Edge::write();
if edge.moves != moves {
return;
}
for (name, shown) in &mut edge.shown {
if *name != monitor {
shown.to(0.0);
}
}
edge.shown.entry(monitor).or_insert_with(sliding).to(1.0);
});
}
}
// gone after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile
fn hide_soon(moves: u32) {
thread::spawn(move || {
thread::sleep(Duration::from_millis(HIDE_MS));
let mut edge = Edge::write();
if edge.moves == moves && edge.at_edge.is_none() && !edge.over_bar {
for shown in edge.shown.values_mut() {
shown.to(0.0);
}
}
});
}
fn sliding() -> Animation {
Animation::new(0.0).duration(Duration::from_millis(SLIDE_MS))
}
/*
* how far a part of the bar slides up out of the monitor: all of `height` at 0, none
* at 1. `open` is the part's own card or panel, which keeps it in while it's open
*/
pub fn hidden_by(shown: f32, open: f32, height: f32) -> f32 {
-height * (1.0 - shown.max(open))
}
// the strip along the top of every monitor; registered before the bar, so the bar lies over it
pub fn view(monitor: &Monitor) -> LayerWindow {
LayerWindow::new()
.width(Full)
.height(1.0)
.anchor_vertical(Vertical::Top)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-edge")
.child(Rectangle::new().width(Parent).height(Parent).on_hover(at_edge(monitor.name.clone())))
}
+19
View File
@@ -28,6 +28,8 @@ pub enum Glyph {
Chip,
// this machine, in the middle of the Wi-Fi and Bluetooth maps
Laptop,
// a network socket, for a wired link
Ethernet,
}
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
@@ -201,5 +203,22 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
}
// a screen on a base
Glyph::Laptop => shapes![frame(0.18, 0.2, 0.82, 0.66).stroke(thin, color), Path::new().move_to(x(0.06), y(0.78)).line_to(x(0.94), y(0.78)).stroke(thin * 1.4, color).cap(Cap::Round)],
// an rj45 socket seen from the front: the outline with its latch notch on top, three contacts inside
Glyph::Ethernet => shapes![
Path::new()
.move_to(x(0.12), y(0.86))
.line_to(x(0.12), y(0.3))
.line_to(x(0.3), y(0.3))
.line_to(x(0.3), y(0.14))
.line_to(x(0.7), y(0.14))
.line_to(x(0.7), y(0.3))
.line_to(x(0.88), y(0.3))
.line_to(x(0.88), y(0.86))
.close()
.stroke(thin, color),
line(0.36, 0.46, 0.36, 0.62),
line(0.5, 0.46, 0.5, 0.62),
line(0.64, 0.46, 0.64, 0.62),
],
}
}
+29 -13
View File
@@ -11,6 +11,7 @@ use amane::{
};
use crate::art::Art;
use crate::edge::{self, Edge, HIDE_H};
use crate::glyph::{Glyph, bold_icon, icon};
use crate::parse::{self, BANDS, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
@@ -232,25 +233,17 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
// the wallpaper's colours for this frame; cover_colors: the open card takes the cover's background
let cover_colors = theme::begin();
let media = Media::read();
let island = Island::read();
let sound = Sound::read();
let art = Art::read();
let here = monitor.name.as_str();
let focused = focused_monitor();
// the other monitors keep their pill while the card is open here
let opened_here = island.opened_on.as_deref().is_none_or(|name| name == here);
/*
* only the focused monitor (and the one with the open card) shows live levels:
* reading Spectrum is what redraws a window for every cava frame, so the other
* windows skip it, rest, and show still bars
*/
let live = focused.as_deref().is_none_or(|name| name == here) || (opened_here && island.expanded);
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
// hidden until the pointer rests on the top edge (edge.rs); an open card keeps it in
let edge_in = Edge::read().shown(here);
let open = if opened_here { island.open.value() } else { 0.0 };
let slide = edge::hidden_by(edge_in, open, HIDE_H);
let window_w = WIDE_W + 2.0 * SHADOW;
@@ -263,6 +256,26 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.space(Zone::Ignore)
.namespace("magic-island");
// out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it
if slide <= -HIDE_H {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
}
let media = Media::read();
let sound = Sound::read();
let art = Art::read();
let focused = focused_monitor();
/*
* only the focused monitor (and the one with the open card) shows live levels:
* reading Spectrum is what redraws a window for every cava frame, so the other
* windows skip it, rest, and show still bars
*/
let live = focused.as_deref().is_none_or(|name| name == here) || (opened_here && island.expanded);
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
let Some(player) = shown(&media, island.selected.as_deref()) else {
/*
* nothing playing: a quiet pill, so the island is always there. never
@@ -276,11 +289,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start)
.translate(0.0, slide)
.child(idle(&theme::now().surfaces())),
);
};
let (grow, pill, reveal) = phases(if opened_here { island.open.value() } else { 0.0 });
let (grow, pill, reveal) = phases(open);
// the pill under the pointer widens a little and glows pink; the card doesn't
let hovered_here = island.pointer_on.as_deref() == Some(here);
@@ -358,6 +372,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Island::write().pointer_on = Some(hovered.clone());
}
edge::over_bar(inside);
hovering(Spot::Island)(inside);
// leaving a pill on another monitor leaves the open card alone
@@ -385,6 +400,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start)
.translate(0.0, slide)
.child(shape),
)
}
+7 -1
View File
@@ -1,9 +1,11 @@
mod art;
mod control;
mod edge;
mod glyph;
mod island;
mod map;
mod parse;
mod prompt;
mod sound;
mod spectrum;
mod system;
@@ -17,14 +19,18 @@ fn main() {
Palette::write().open(&art::palette_file(), 8);
// one island on every monitor (the card opens only on one of them), a workspace pill top left
// and the control centre top right on each
// and the control centre top right on each; all three hidden until the pointer rests on the
// top edge (edge.rs), whose strip comes first so the bar lies over it
App::new()
.font("Fira Code")
.ipc("island", island::command)
.ipc("control", control::command)
.window_per_monitor(edge::view)
.window_per_monitor(island::view)
.window_per_monitor(workspaces::view)
.window_per_monitor(control::view)
.window_per_monitor(control::menu_view)
// last, so the popup lies over the control centre
.window_per_monitor(prompt::view)
.run();
}
+59 -14
View File
@@ -9,7 +9,7 @@ use std::f32::consts::{PI, TAU};
use std::sync::OnceLock;
use std::time::Instant;
use amane::{Blend, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes};
use amane::{Blend, Button, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes};
use crate::glyph::{Glyph, glyph_shapes};
use crate::island::{CHIP_OPEN, faded};
@@ -33,6 +33,12 @@ pub const NODES: usize = 12;
// the wobbling line of what isn't connected
const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85);
// a click on the bubble shows its rings at least until all three have run out once; a ring takes RING_S
const RING_S: f32 = 1.6;
const RING_GAP_S: f32 = 0.5;
pub const RIPPLE_S: f32 = 2.0 * RING_GAP_S + RING_S;
const RING_REACH: f32 = 170.0;
#[derive(Clone, Copy, PartialEq)]
pub enum Link {
Idle,
@@ -51,22 +57,36 @@ pub struct Node {
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
pub on_click: Option<Box<dyn Fn()>>,
// left and right clicks, told apart by the button
pub on_click: Option<Box<dyn Fn(Button)>>,
}
// this desktop's bubble in the middle: its name, what to know, and a click that searches again
pub struct Centre<'a> {
pub host: &'a str,
pub note: Option<&'a str>,
// seconds since a search began (see Control::searching): rings run out from the bubble
pub searching: Option<f32>,
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
pub on_click: Box<dyn Fn()>,
}
// seconds since the shell first drew a map: the clock every float and wobble runs on
fn clock() -> f32 {
pub fn clock() -> f32 {
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f32()
}
pub fn map(width: f32, height: f32, host: &str, nodes: Vec<Node>, scanning: bool, note: Option<&str>) -> Rectangle {
pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectangle {
let theme = theme::now();
let t = clock();
let (cx, cy) = (width / 2.0, height / 2.0);
let bubble = BUBBLE + (t * 1.3).sin() * 1.5;
let bubble = BUBBLE * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5;
// the ellipse the farthest ones sit on, wider than tall like the menu, with room for their labels above or below
let (rx, ry) = (width / 2.0 - LABEL_W / 2.0 - 6.0, cy - NODE_R - LABEL_H - 9.0);
@@ -172,7 +192,7 @@ pub fn map(width: f32, height: f32, host: &str, nodes: Vec<Node>, scanning: bool
let target = Rectangle::new().width(2.0 * NODE_R).height(2.0 * NODE_R).radius(Full).translate(x - NODE_R, y - NODE_R).on_hover(node.on_hover);
let target = match node.on_click {
Some(click) => target.cursor(Pointer).on_click(move |_| click()),
Some(click) => target.cursor(Pointer).on_click(click),
None => target,
};
@@ -201,39 +221,64 @@ pub fn map(width: f32, height: f32, host: &str, nodes: Vec<Node>, scanning: bool
running.stroke(2.2, faded(theme.accent, 220)).cap(Cap::Round),
];
// while it looks for networks or devices, rings ripple out from the bubble
if scanning {
for ring in 0..2 {
let grown = (t / 1.8 + ring as f32 * 0.5).fract();
// while it searches, rings run out from the bubble and fade, the first one from the moment of the click
if let Some(since) = centre.searching {
for ring in 0..3 {
let age = since - ring as f32 * RING_GAP_S;
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 6.0 + grown * 90.0).stroke(1.5, faded(theme.accent, (110.0 * (1.0 - grown)) as u8))));
if age < 0.0 {
continue;
}
let grown = (age / RING_S).fract();
let eased = 1.0 - (1.0 - grown).powi(2);
lines.push(Box::new(
Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * RING_REACH).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)),
));
}
}
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 9.0).fill(faded(theme.accent, 38))));
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble).fill(theme.accent)));
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble).fill(Color::blend(theme.accent, theme.flame, 0.3 * centre.lit))));
lines.extend(glyph_shapes(Glyph::Laptop, 34.0, theme.on_accent, 0.0, (cx - 17.0, cy - 17.0)));
lines.extend(dots);
lines.extend(discs);
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))];
// this desktop's name under its bubble, and what to know under that
// this desktop's name under its bubble, and what to know under that (on hover: what a click does)
layers.push(Box::new(
Rectangle::new()
.width(LABEL_W * 2.0)
.height(16.0)
.align_child(Center, Center)
.translate(cx - LABEL_W, cy + BUBBLE + 12.0)
.child(Text::new(host).size(12.0).weight(Weight::Bold).color(theme.text)),
.child(Text::new(centre.host).size(12.0).weight(Weight::Bold).color(theme.text)),
));
let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note };
if let Some(note) = note {
layers.push(Box::new(
Rectangle::new().width(LABEL_W * 3.0).height(16.0).align_child(Center, Center).translate(cx - LABEL_W * 1.5, cy + BUBBLE + 32.0).child(Text::new(note).size(11.0).color(theme.dim)),
));
}
// the bubble's own target, under the nodes'
let on_click = centre.on_click;
layers.push(Box::new(
Rectangle::new()
.width(2.0 * BUBBLE)
.height(2.0 * BUBBLE)
.radius(Full)
.translate(cx - BUBBLE, cy - BUBBLE)
.cursor(Pointer)
.on_hover(centre.on_hover)
.on_click(move |_| on_click()),
));
layers.extend(floating);
Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height))
+189
View File
@@ -450,6 +450,157 @@ pub fn battery_minutes(status: &str, now: u64, full: u64, rate: u64) -> Option<u
Some((left as f64 / rate as f64 * 60.0).round() as u32)
}
/*
* what iwctl and bluetoothctl print, as plain lines: they write for people, with
* colours, carriage returns, backspaces and bluetoothctl's own "[bluetoothctl]>" prompt
*/
pub fn plain_lines(text: &str) -> Vec<String> {
let mut plain = String::new();
let mut letters = text.chars().peekable();
while let Some(letter) = letters.next() {
match letter {
// an escape sequence, ESC [ ... up to its final letter
'\u{1b}' => {
if letters.peek() == Some(&'[') {
letters.next();
for inner in letters.by_ref() {
if ('@'..='~').contains(&inner) {
break;
}
}
}
}
'\r' => plain.push('\n'),
'\u{8}' | '\u{1}' | '\u{2}' => {}
_ => plain.push(letter),
}
}
plain
.lines()
.map(|line| line.trim().trim_start_matches("[bluetoothctl]>").trim().to_string())
.filter(|line| !line.is_empty())
.collect()
}
// what bluetoothctl's own agent asks, and how a pairing or connecting ends
#[derive(Debug, Clone, PartialEq)]
pub enum Pairing {
AskPin,
AskPasskey,
Confirm(String),
Accept,
Authorize,
Show(String),
Paired,
Connected,
Failed(String),
}
pub fn pairing_event(line: &str) -> Option<Pairing> {
let line = line.trim_start_matches("[agent]").trim();
// the agent's questions first: "Enter passkey" also holds "passkey"
if line.starts_with("Enter PIN code") {
return Some(Pairing::AskPin);
}
if line.starts_with("Enter passkey") {
return Some(Pairing::AskPasskey);
}
if let Some(rest) = line.strip_prefix("Confirm passkey ") {
return Some(Pairing::Confirm(rest.split_whitespace().next().unwrap_or("").to_string()));
}
if line.starts_with("Accept pairing") {
return Some(Pairing::Accept);
}
if line.starts_with("Authorize service") {
return Some(Pairing::Authorize);
}
// a code to type on the device itself
if let Some(code) = line.strip_prefix("Passkey: ").or_else(|| line.strip_prefix("PIN code: ")) {
return Some(Pairing::Show(code.split_whitespace().next().unwrap_or("").to_string()));
}
if line.contains("Pairing successful") {
return Some(Pairing::Paired);
}
if line.contains("Connection successful") {
return Some(Pairing::Connected);
}
if let Some(error) = line.strip_prefix("Failed to pair: ").or_else(|| line.strip_prefix("Failed to connect: ")) {
return Some(Pairing::Failed(error.trim().to_string()));
}
if line.starts_with("Device ") && line.ends_with("not available") {
return Some(Pairing::Failed(String::from("not available")));
}
None
}
// a bluez error, the way a person would say it; anything unknown as bluez wrote it
pub fn bluez_reason(error: &str) -> String {
if error == "not available" {
return String::from("Not in range: let it be found, then retry");
}
let name = error.split_whitespace().next().unwrap_or("");
let detail = error[name.len()..].trim();
let reason = match name.trim_start_matches("org.bluez.Error.") {
"AuthenticationFailed" => "Wrong PIN or passkey, or the device said no",
"AuthenticationCanceled" | "AuthenticationRejected" => "The pairing was turned down",
"AuthenticationTimeout" => "No answer in time",
"ConnectionAttemptFailed" => "Out of range, or switched off",
"AlreadyExists" => "It's paired already",
"InProgress" => "It's already pairing or connecting",
"NotReady" => "Bluetooth isn't ready",
"Failed" if detail.contains("profile-unavailable") => "It offers nothing this computer can use",
_ => return error.to_string(),
};
reason.to_string()
}
/*
* the files iwd may keep a network's profile in, under /var/lib/iwd: its name if
* that's only letters, digits, '-' and '_', else '=' and the name in hex
*/
pub fn iwd_profiles(ssid: &str) -> Vec<String> {
let hex: String = ssid.bytes().map(|byte| format!("{byte:02x}")).collect();
let plain = !ssid.is_empty() && ssid.chars().all(|letter| letter.is_ascii_alphanumeric() || letter == '-' || letter == '_');
if plain { vec![format!("{ssid}.psk"), format!("={hex}.psk")] } else { vec![format!("={hex}.psk")] }
}
// the password in an iwd profile, or the pre-shared key when only that is kept
pub fn psk_passphrase(profile: &str) -> Option<String> {
let value = |key: &str| profile.lines().map(str::trim).find_map(|line| line.strip_prefix(key)).map(|value| value.trim().to_string());
value("Passphrase=").or_else(|| value("PreSharedKey="))
}
// a link speed in Mbit/s the way a person reads it: 1000 is "1 Gb/s", 2500 "2.5 Gb/s", 325 "325 Mb/s"
pub fn speed_words(mbit: u32) -> String {
if mbit >= 1000 {
let gbit = f64::from(mbit) / 1000.0;
if (gbit - gbit.round()).abs() < 0.05 { format!("{} Gb/s", gbit.round()) } else { format!("{gbit:.1} Gb/s") }
} else {
format!("{mbit} Mb/s")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -612,6 +763,44 @@ Sink #80
assert_eq!(gpu_name("00:02.0 \"VGA\" \"Intel Corporation\" \"Raptor Lake-S UHD Graphics\""), "Raptor Lake-S UHD Graphics");
}
#[test]
fn reads_bluetoothctl_iwctl_and_iwd_profiles() {
// as bluetoothctl printed it on the desktop, through a pipe
let printed = "\u{1b}[0;94m[bluetoothctl]> \u{1b}[0m\r \rAgent registered\n\u{1b}[0;94m[bluetoothctl]> \u{1b}[0mpair 00:11:22:33:44:55\nDevice 00:11:22:33:44:55 not available\n\u{8}\u{8}[\u{1b}[0;93mCHG\u{1b}[0m] Controller 90:65:84:3C:6D:B4 Pairable: yes\n";
let lines = plain_lines(printed);
assert_eq!(lines, ["Agent registered", "pair 00:11:22:33:44:55", "Device 00:11:22:33:44:55 not available", "[CHG] Controller 90:65:84:3C:6D:B4 Pairable: yes"]);
assert_eq!(pairing_event(&lines[2]), Some(Pairing::Failed(String::from("not available"))));
assert_eq!(pairing_event(&lines[0]), None);
// the agent's questions and the endings, as bluez's client words them
assert_eq!(pairing_event("[agent] Enter PIN code:"), Some(Pairing::AskPin));
assert_eq!(pairing_event("[agent] Enter passkey (number in 0-999999):"), Some(Pairing::AskPasskey));
assert_eq!(pairing_event("[agent] Confirm passkey 123456 (yes/no):"), Some(Pairing::Confirm(String::from("123456"))));
assert_eq!(pairing_event("[agent] Accept pairing (yes/no):"), Some(Pairing::Accept));
assert_eq!(pairing_event("[agent] Passkey: 654321"), Some(Pairing::Show(String::from("654321"))));
assert_eq!(pairing_event("Pairing successful"), Some(Pairing::Paired));
assert_eq!(pairing_event("Failed to pair: org.bluez.Error.AuthenticationFailed"), Some(Pairing::Failed(String::from("org.bluez.Error.AuthenticationFailed"))));
assert_eq!(bluez_reason("org.bluez.Error.AuthenticationFailed"), "Wrong PIN or passkey, or the device said no");
assert_eq!(bluez_reason("org.bluez.Error.Failed br-connection-profile-unavailable"), "It offers nothing this computer can use");
assert_eq!(bluez_reason("org.bluez.Error.Odd"), "org.bluez.Error.Odd");
assert_eq!(plain_lines("Invalid network name 'NoSuchNetwork'\n"), ["Invalid network name 'NoSuchNetwork'"]);
assert_eq!(iwd_profiles("WLAN-752780"), ["WLAN-752780.psk", "=574c414e2d373532373830.psk"]);
assert_eq!(iwd_profiles("FRITZ!Box 7590"), ["=465249545a21426f782037353930.psk"]);
assert_eq!(psk_passphrase("[Security]\nPreSharedKey=abcd\nPassphrase=hunter22\n"), Some(String::from("hunter22")));
assert_eq!(psk_passphrase("[Security]\nPreSharedKey=abcd\n"), Some(String::from("abcd")));
assert_eq!(psk_passphrase("[Settings]\nAutoConnect=true\n"), None);
assert_eq!(speed_words(1000), "1 Gb/s");
assert_eq!(speed_words(2500), "2.5 Gb/s");
assert_eq!(speed_words(325), "325 Mb/s");
assert_eq!(speed_words(1201), "1.2 Gb/s");
}
#[test]
fn reads_power_and_names() {
// 40 Wh left at 20 W: two hours; 10 Wh to go at 30 W: twenty minutes
+681
View File
@@ -0,0 +1,681 @@
/*
* the small popup over the control centre: it asks what joining a Wi-Fi network or
* pairing a Bluetooth device needs, shows a known network's saved password, and says
* why a connection failed. the shell serves nothing on D-Bus for this: joining goes
* through iwctl and pairing through a bluetoothctl session, which bring their own
* agents. bluetoothctl's agent asks on its output and takes the answer on its input
*/
use std::collections::HashMap;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::thread;
use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin,
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, Start, Text, TextInput, Vertical, Weight, Widget,
Zone, children,
};
use crate::control::{self, Control, RIGHT};
use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled};
use crate::map::MAP_W;
use crate::parse::{self, Pairing};
use crate::system;
use crate::theme;
const POPUP_W: f32 = 360.0;
const POPUP_PAD: f32 = 16.0;
const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD;
const HEADER_H: f32 = 36.0;
const FIELD_H: f32 = 36.0;
const CODE_H: f32 = 40.0;
const LINE_H: f32 = 18.0;
const BUTTON_W: f32 = 92.0;
const BUTTON_H: f32 = 32.0;
const GAP: f32 = 14.0;
// a bluetoothctl session that hasn't ended by then is stopped
const SESSION_S: u64 = 90;
#[derive(Clone, PartialEq)]
pub enum Ask {
// something to type and send: a new network's password, a Bluetooth PIN or passkey
Password,
Pin,
Passkey,
// yes or no: does this passkey show on the device too (empty: may it pair at all)
Confirm(String),
// type this on the device's own keyboard
Show(String),
// a known network's saved password
Saved(String),
// why joining, pairing or connecting didn't work
Failed { title: String, reason: String },
}
#[derive(Clone, PartialEq)]
pub struct Request {
pub ask: Ask,
pub wifi: bool,
// the network's or device's name, and what iwctl joins: the SSID
pub name: String,
target: String,
// a text input of its own, so nothing typed for an earlier question shows up
input: &'static str,
}
pub struct Prompt {
request: Option<Request>,
// what's typed so far, for the button that sends it
typed: String,
// the saved password shown went to the clipboard
copied: bool,
appear: Animation,
hover: HashMap<u8, Animation>,
}
impl Service for Prompt {
fn new() -> Self {
Self { request: None, typed: String::new(), copied: false, appear: Animation::new(0.0), hover: HashMap::new() }
}
// changes only through input, iwctl's answers and bluetoothctl's questions
fn listen() {}
}
// the bluetoothctl that's pairing or connecting now: its input for the agent's answers, the process to stop it
struct Session {
id: u32,
child: Child,
input: ChildStdin,
}
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
// how many sessions and questions there have been, for their ids
static COUNT: Mutex<u32> = Mutex::new(0);
fn session() -> MutexGuard<'static, Option<Session>> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner)
}
fn next() -> u32 {
let mut count = COUNT.lock().unwrap_or_else(PoisonError::into_inner);
*count += 1;
*count
}
// ponytail: one leaked input id per question (a few bytes); fine for questions asked by hand
fn new_input() -> &'static str {
Box::leak(format!("magic-prompt-{}", next()).into_boxed_str())
}
fn open(ask: Ask, wifi: bool, name: &str, target: &str) {
let mut prompt = Prompt::write();
prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() });
prompt.typed.clear();
prompt.copied = false;
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220));
prompt.appear.to(1.0);
}
fn close() {
Prompt::write().request = None;
}
fn failed(wifi: bool, name: &str, what: &str, reason: String) {
control::clear_pending();
open(Ask::Failed { title: format!("Couldn't {what} {name}"), reason }, wifi, name, "");
}
// Wi-Fi
// the Wi-Fi map: a saved or open network is joined at once, a new one asks for its password first
pub fn join(ssid: &str, needs_password: bool) {
if needs_password {
open(Ask::Password, true, ssid, ssid);
} else {
joining(ssid.to_string(), None);
}
}
fn joining(ssid: String, passphrase: Option<String>) {
thread::spawn(move || {
if let Err(reason) = system::join_wifi(&ssid, passphrase.as_deref()) {
// iwd says "Operation failed" when the handshake fails, which a wrong password is the usual cause of
let reason = if passphrase.is_some() && reason == "Operation failed" { String::from("Operation failed. Is the password right?") } else { reason };
failed(true, &ssid, "join", reason);
}
system::refresh();
});
}
// a right-click on a saved network: its password, read from iwd's profile
pub fn reveal(ssid: &str) {
let ssid = ssid.to_string();
thread::spawn(move || match system::saved_password(&ssid) {
Ok(password) => open(Ask::Saved(password), true, &ssid, &ssid),
Err(reason) => open(Ask::Failed { title: format!("No password for {ssid}"), reason }, true, &ssid, ""),
});
}
// the shown password to the clipboard through wl-copy, on its input so it never shows in the process list
fn copy(password: &str) {
let password = password.to_string();
thread::spawn(move || {
let Ok(mut child) = Command::new("wl-copy").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
return;
};
let written = child.stdin.take().is_some_and(|mut input| input.write_all(password.as_bytes()).is_ok());
// wl-copy leaves a child of its own behind to serve the clipboard and exits
if child.wait().is_ok_and(|status| status.success()) && written {
Prompt::write().copied = true;
}
});
}
// Bluetooth
/*
* pairs (then trusts and connects) or connects a device through bluetoothctl. its
* agent's questions open the popup, whose answers go to its input; how it ends shows
* as a reason when it fails. one session at a time: a new one stops the last
*/
pub fn bluetooth(address: &str, name: &str, pair: bool) {
stop_session();
let (address, name) = (address.to_string(), name.to_string());
thread::spawn(move || run_session(&address, &name, pair));
}
fn run_session(address: &str, name: &str, pair: bool) {
let spawned = Command::new("bluetoothctl").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
let Ok(mut child) = spawned else {
failed(false, name, if pair { "pair" } else { "connect" }, String::from("bluetoothctl couldn't start"));
return;
};
let (Some(mut input), Some(mut output)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return;
};
// its own agent, the default one while it runs, so the device's questions come to it
let first = if pair { format!("pair {address}\n") } else { format!("connect {address}\n") };
let _ = input.write_all(format!("agent KeyboardDisplay\ndefault-agent\n{first}").as_bytes());
let id = next();
*session() = Some(Session { id, child, input });
// stopped if it hasn't ended in time, which ends the reading below too
thread::spawn(move || {
thread::sleep(Duration::from_secs(SESSION_S));
let late = session().as_ref().is_some_and(|session| session.id == id);
if late {
stop_session();
close();
}
});
let mut printed = Vec::new();
let mut chunk = [0_u8; 512];
// whole lines already looked at, and a question on the last, unfinished line already asked
let mut seen = 0;
let mut asked: Option<String> = None;
let mut paired = !pair;
while let Ok(read) = output.read(&mut chunk) {
if read == 0 {
break;
}
printed.extend_from_slice(&chunk[..read]);
let text = String::from_utf8_lossy(&printed).into_owned();
let finished = text.ends_with('\n') || text.ends_with('\r');
let lines = parse::plain_lines(&text);
// the agent's questions end without a newline: the last line counts once it's a question
let whole = if finished { lines.len() } else { lines.len().saturating_sub(1) };
let mut events: Vec<(String, Pairing)> = Vec::new();
for line in &lines[seen.min(whole)..whole] {
if asked.as_deref() != Some(line.as_str())
&& let Some(event) = parse::pairing_event(line)
{
events.push((line.clone(), event));
}
}
seen = whole;
if !finished
&& let Some(last) = lines.last()
&& asked.as_deref() != Some(last.as_str())
&& let Some(event) = parse::pairing_event(last)
{
asked = Some(last.clone());
events.push((last.clone(), event));
}
for (_, event) in events {
match event {
Pairing::AskPin => open(Ask::Pin, false, name, address),
Pairing::AskPasskey => open(Ask::Passkey, false, name, address),
Pairing::Confirm(code) => open(Ask::Confirm(code), false, name, address),
Pairing::Accept => open(Ask::Confirm(String::new()), false, name, address),
// a device asking to use one of its services: pairing it was the yes
Pairing::Authorize => send("yes\n"),
Pairing::Show(code) => open(Ask::Show(code), false, name, address),
Pairing::Paired => {
paired = true;
// a code it showed is done with; trusted, it reconnects by itself later
close_unless_failed();
send(&format!("trust {address}\nconnect {address}\n"));
}
Pairing::Connected => {
close_unless_failed();
send("quit\n");
}
Pairing::Failed(error) => {
send("quit\n");
failed(false, name, if paired { "connect" } else { "pair" }, parse::bluez_reason(&error));
}
}
}
}
// ended: reap it, unless a newer session took its place
let ended = {
let mut current = session();
if current.as_ref().is_some_and(|session| session.id == id) { current.take() } else { None }
};
if let Some(mut ended) = ended {
let _ = ended.child.wait();
}
control::clear_pending();
system::refresh();
}
// an answer for bluetoothctl's agent, or a command for bluetoothctl
fn send(text: &str) {
if let Some(session) = session().as_mut() {
let _ = session.input.write_all(text.as_bytes());
}
}
fn stop_session() {
let Some(mut stopped) = session().take() else {
return;
};
// its agent goes with it, so bluez gives up on the pairing
let _ = stopped.child.kill();
thread::spawn(move || {
let _ = stopped.child.wait();
});
}
// a question or code still showing goes; a reason already showing stays
fn close_unless_failed() {
let mut prompt = Prompt::write();
if !matches!(prompt.request.as_ref().map(|request| &request.ask), Some(Ask::Failed { .. })) {
prompt.request = None;
}
}
// the popup's answers
// whether what's typed can be sent
fn ready(ask: &Ask, typed: &str) -> bool {
match ask {
// wpa passphrases are 8 to 63 characters
Ask::Password => (8..=63).contains(&typed.chars().count()),
Ask::Pin => !typed.trim().is_empty(),
Ask::Passkey => typed.trim().parse::<u32>().is_ok_and(|passkey| passkey <= 999_999),
Ask::Show(_) => false,
Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } => true,
}
}
// the user's answer: typed text, or yes, or just closing what was shown
pub fn answer(typed: &str) {
let Some(request) = Prompt::read().request.clone() else {
return;
};
if !ready(&request.ask, typed) {
return;
}
close();
match request.ask {
Ask::Password => joining(request.target, Some(typed.to_string())),
Ask::Pin | Ask::Passkey => send(&format!("{}\n", typed.trim())),
Ask::Confirm(_) => send("yes\n"),
Ask::Show(_) | Ask::Saved(_) | Ask::Failed { .. } => {}
}
}
// no: a pairing stops (bluetoothctl and its agent go), a password not given joins nothing
pub fn refuse() {
let Some(request) = Prompt::write().request.take() else {
return;
};
match request.ask {
Ask::Password => control::clear_pending(),
Ask::Pin | Ask::Passkey | Ask::Confirm(_) | Ask::Show(_) => {
stop_session();
control::clear_pending();
}
Ask::Saved(_) | Ask::Failed { .. } => {}
}
}
// `amane ipc call control prompt answer <text>|cancel`, for scripts and tests
pub fn command(words: &[&str]) -> String {
match words {
["answer", text] => answer(text),
["answer"] => answer(""),
["cancel"] => refuse(),
_ => return String::from("usage: control prompt answer <text>|cancel"),
}
String::from("ok")
}
pub fn showing() -> bool {
Prompt::read().request.is_some()
}
// for `control status`: what the popup shows, as one word
pub fn status() -> &'static str {
match Prompt::read().request.as_ref().map(|request| &request.ask) {
None => "none",
Some(Ask::Password) => "password",
Some(Ask::Pin) => "pin",
Some(Ask::Passkey) => "passkey",
Some(Ask::Confirm(_)) => "confirm",
Some(Ask::Show(_)) => "show",
Some(Ask::Saved(_)) => "saved",
Some(Ask::Failed { .. }) => "failed",
}
}
fn hovering(spot: u8) -> impl Fn(bool) + 'static {
move |inside| {
Prompt::write()
.hover
.entry(spot)
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
.to(if inside { 1.0 } else { 0.0 });
}
}
/*
* the popup: a small card over the middle of the control centre's map, with the
* question, a field, a code or a reason, and its buttons. it takes the keyboard
* while it shows: Enter answers, Escape cancels. on one monitor only, the control
* centre's or the focused one
*/
pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let prompt = Prompt::read();
let here = {
let control = Control::read();
let on = if control.expanded() { control.opened_on().map(String::from) } else { island::focused_monitor() };
on.is_none_or(|name| name == monitor.name)
};
let window = |width: f32, height: f32| {
LayerWindow::new()
.width(width)
.height(height)
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Right)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-prompt")
};
// never hidden (see the island's view): a tiny, empty click-through window instead
let Some(request) = prompt.request.as_ref().filter(|_| here) else {
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
};
let theme = theme::now();
let colors = theme.surfaces();
let name = &request.name;
let (title, question) = match &request.ask {
Ask::Password => (format!("Join {name}"), "Enter its Wi-Fi password"),
Ask::Pin => (format!("Pair {name}"), "Enter its PIN"),
Ask::Passkey => (format!("Pair {name}"), "Enter the six-digit passkey it shows"),
Ask::Confirm(code) if code.is_empty() => (format!("Pair {name}"), "Allow it to pair with this computer?"),
Ask::Confirm(_) => (format!("Pair {name}"), "Does the device show this code too?"),
Ask::Show(_) => (format!("Pair {name}"), "Type this code on it, then press Enter"),
Ask::Saved(_) => (name.clone(), "Its saved Wi-Fi password"),
Ask::Failed { title, .. } => (title.clone(), "What went wrong"),
};
let mark = match (&request.ask, request.wifi) {
(Ask::Failed { .. }, _) => Glyph::Muted,
(_, true) => Glyph::Lock,
(_, false) => Glyph::Bluetooth,
};
let header = Row::new(children![
Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)),
Column::new(children![
Text::new(title).size(13.0).weight(Weight::Bold).color(theme.text).elide(),
Text::new(question).size(11.0).color(theme.dim).elide(),
])
.gap(3.0)
.width(INNER_W - HEADER_H - 12.0),
])
.gap(12.0)
.align(Center);
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(header)];
let mut body_h = 0.0;
match &request.ask {
Ask::Password | Ask::Pin | Ask::Passkey => {
let field = TextInput::new(request.input)
.size(13.0)
.color(theme.text)
.placeholder(match request.ask {
Ask::Password => "password",
Ask::Pin => "PIN",
_ => "000000",
})
.focused()
.on_change(|typed| Prompt::write().typed = typed)
.on_submit(|typed| answer(&typed));
let field = if request.ask == Ask::Password { field.password() } else { field };
rows.push(Box::new(
Rectangle::new()
.width(INNER_W)
.height(FIELD_H)
.radius(10.0)
.fill(CHIP)
.border(1.0, theme.outline)
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
.align_child(Start, Center)
.child(field),
));
body_h = FIELD_H;
}
Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => {
rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent)));
body_h = CODE_H;
}
Ask::Saved(password) => {
let password = password.clone();
// a click on it copies it too
rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password))));
body_h = CODE_H;
}
Ask::Failed { reason, .. } => {
rows.push(Box::new(Rectangle::new().width(INNER_W).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide())));
body_h = LINE_H;
}
Ask::Confirm(_) | Ask::Show(_) => {}
}
// the buttons: Cancel while something is asked (Copy for a password), and what answers (just a close for what's only shown)
let lit = |spot: u8| prompt.hover.get(&spot).map_or(0.0, Animation::value);
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
if let Ask::Saved(password) = &request.ask {
let password = password.clone();
let label = if prompt.copied { "Copied" } else { "Copy" };
buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password))));
} else if !matches!(request.ask, Ask::Failed { .. }) {
buttons.push(Box::new(button("Cancel", Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse())));
}
let primary = match request.ask {
Ask::Password => Some("Join"),
Ask::Show(_) => None,
Ask::Saved(_) => Some("Close"),
Ask::Failed { .. } => Some("OK"),
_ => Some("Pair"),
};
if let Some(label) = primary {
let ok = ready(&request.ask, &prompt.typed);
let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { CHIP };
let ink = if ok { theme.on_accent } else { theme.dim };
let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1));
// a click answers with what's typed, as Enter does
let primary = if ok {
let typed = prompt.typed.clone();
primary.cursor(Pointer).on_click(move |_| answer(&typed))
} else {
primary
};
buttons.push(Box::new(primary));
}
rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box<dyn Widget>]).justify(End).width(INNER_W).height(BUTTON_H)));
let parts = if body_h > 0.0 { 3.0 } else { 2.0 };
let card_h = 2.0 * POPUP_PAD + HEADER_H + body_h + BUTTON_H + GAP * (parts - 1.0);
let appear = prompt.appear.value();
let content = Rectangle::new().width(POPUP_W).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP));
let card = Rectangle::new()
.width(POPUP_W)
.height(card_h)
.radius(22.0)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme.outline, theme.accent, 0.5))
.shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0))
.translate(0.0, 8.0 * (1.0 - appear))
.child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors));
// over the middle of the map menu, a little below the control centre's top
let right = RIGHT + (MAP_W - POPUP_W) / 2.0 - SHADOW;
let top = TOP_GAP + 110.0 - SHADOW;
/*
* the keyboard only while there's something to type: the other popups come on their
* own (a reason, a code) and mustn't take keys meant for another window; a click
* gives them the keyboard for Enter and Escape
*/
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey);
let window = window(POPUP_W + 2.0 * SHADOW, card_h + 2.0 * SHADOW)
.margin(Margin { top: top.max(0.0) as i32, right: right.max(0.0) as i32, bottom: 0, left: 0 })
.keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand })
.on_key(|key| match key {
Key::Escape => refuse(),
// nothing to type: Enter is the yes, or the close
Key::Enter => {
let answers = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. }));
if answers {
answer("");
}
}
_ => {}
});
let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: POPUP_W as i32, height: card_h.ceil() as i32 };
window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(SHADOW).align_child(Start, Start).child(card))
}
// a code spaced in threes, the way devices show it
fn spaced(code: &str) -> String {
code.chars().enumerate().flat_map(|(index, digit)| (index > 0 && index % 3 == 0).then_some(' ').into_iter().chain(std::iter::once(digit))).collect()
}
fn big_line(text: &str, size: f32, color: Color) -> Rectangle {
Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide())
}
fn button(label: &str, fill: Color, ink: Color, lit: f32) -> Rectangle {
Rectangle::new().width(BUTTON_W).height(BUTTON_H).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(12.0).weight(Weight::Bold).color(ink))
}
+147 -11
View File
@@ -30,8 +30,9 @@ pub struct Wifi {
// the iwd daemon runs; without it the tile is grey and a click starts its service
pub service: bool,
// the device's object path, like /net/connman/iwd/0/4; empty without a device
// the device's object path, like /net/connman/iwd/0/4, and its interface name for iwctl, like wlan0; empty without a device
pub device: String,
pub device_name: String,
pub powered: bool,
// the station's state: connected, connecting, disconnected, ...
@@ -52,6 +53,21 @@ pub struct Wifi {
// iwd is scanning for networks
pub scanning: bool,
// the connected network's receive link rate in Mbit/s, looked up at most once a minute
pub rate: Option<u32>,
}
// a wired network card (not wi-fi, not virtual), from sysfs
#[derive(Clone, PartialEq)]
pub struct Wired {
pub name: String,
// a cable is in and the link is up
pub connected: bool,
// the link speed it negotiated, in Mbit/s
pub speed: Option<u32>,
}
#[derive(Clone, PartialEq)]
@@ -60,6 +76,9 @@ pub struct Network {
pub path: String,
pub name: String,
pub secured: bool,
// 802.1x: a username and password, which the popup doesn't ask for
pub enterprise: bool,
pub known: bool,
pub connected: bool,
@@ -91,6 +110,7 @@ impl Vpn {
#[derive(Default)]
pub struct Links {
pub wifi: Wifi,
pub wired: Vec<Wired>,
pub vpns: Vec<Vpn>,
// bluez's openrc service is installed, so a click can start it
@@ -117,6 +137,7 @@ impl Service for Links {
// reads everything again outside the lock, and writes only what changed
pub fn refresh() {
let wifi = wifi();
let wired = wired();
let vpns = vpns();
let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists();
let host = read(Path::new("/proc/sys/kernel/hostname"));
@@ -124,14 +145,62 @@ pub fn refresh() {
let stale = {
let links = Links::read();
links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
links.wifi != wifi || links.wired != wired || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
};
if stale {
*Links::write() = Links { wifi, vpns, bluetooth_service, host };
*Links::write() = Links { wifi, wired, vpns, bluetooth_service, host };
}
}
// every real wired card: one with a device behind it, that isn't wi-fi
fn wired() -> Vec<Wired> {
let Ok(entries) = fs::read_dir("/sys/class/net") else {
return Vec::new();
};
let mut wired: Vec<Wired> = entries
.flatten()
.map(|entry| entry.path())
.filter(|card| read(&card.join("type")) == "1" && card.join("device").exists() && !card.join("wireless").exists() && !card.join("phy80211").exists())
.map(|card| {
// carrier and speed can't be read while the card is down
let connected = read(&card.join("carrier")) == "1";
let speed = read(&card.join("speed")).parse().ok().filter(|speed| connected && *speed > 0);
Wired { name: card.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(), connected, speed }
})
.collect();
wired.sort_by(|one, other| one.name.cmp(&other.name));
wired
}
/*
* the connected network's receive rate from iwd's diagnostics (in 100 kbit/s), asked at
* most once a minute, and again when the network changes: it shifts with every packet
*/
fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
static LAST: Mutex<Option<(Instant, String, Option<u32>)>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((asked, on, rate)) = last.as_ref()
&& on == network
&& asked.elapsed() < Duration::from_secs(60)
{
return *rate;
}
let diagnostics = bus.call(IWD, device, "net.connman.iwd.StationDiagnostic", "GetDiagnostics", &[]);
let rate = Some(diagnostics.get("RxBitrate").number()).filter(|rate| *rate > 0.0).map(|rate| (rate / 10.0).round() as u32);
*last = Some((Instant::now(), network.to_string(), rate));
rate
}
fn wifi() -> Wifi {
let bus = Bus::system();
@@ -179,6 +248,7 @@ fn wifi() -> Wifi {
path: path.to_string(),
name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open",
enterprise: found.get("Type").text() == "8021x",
known: !found.get("KnownNetwork").text().is_empty(),
connected: path == connected && joined,
connecting: path == connected && !joined,
@@ -200,9 +270,12 @@ fn wifi() -> Wifi {
0
};
let rate = if joined { wifi_rate(&bus, path, network) } else { None };
Wifi {
service: true,
device: path.clone(),
device_name: device.get("Name").text().to_string(),
powered: device.get("Powered").bool(),
state: station.get("State").text().to_string(),
network: network.to_string(),
@@ -213,6 +286,7 @@ fn wifi() -> Wifi {
clients,
networks,
scanning: station.get("Scanning").bool(),
rate,
}
}
@@ -344,25 +418,87 @@ 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", &[]);
/*
* joins a network with iwctl, which brings its own agent (the password goes on its
* command line, --dont-ask without one) and says why when it fails. blocks until
* iwd has joined or given up, so call it off the drawing thread
*/
pub fn join_wifi(ssid: &str, passphrase: Option<&str>) -> Result<(), String> {
let device = Links::read().wifi.device_name.clone();
if device.is_empty() {
return Err(String::from("iwd sees no Wi-Fi device"));
}
let mut iwctl = Command::new("iwctl");
match passphrase {
Some(passphrase) => iwctl.arg("--passphrase").arg(passphrase),
None => iwctl.arg("--dont-ask"),
};
let output = iwctl.args(["station", &device, "connect", ssid]).stdin(Stdio::null()).output().map_err(|error| format!("iwctl couldn't start: {error}"))?;
if output.status.success() {
return Ok(());
}
let printed = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
Err(parse::plain_lines(&printed).pop().unwrap_or_else(|| String::from("iwctl gave no reason")))
}
/*
* a known network's password, from iwd's profile: /var/lib/iwd is root's, so through
* doas. the file name comes from parse::iwd_profiles, never straight from the SSID
*/
pub fn saved_password(ssid: &str) -> Result<String, String> {
for profile in parse::iwd_profiles(ssid) {
let Ok(output) = Command::new("doas").args(["-n", "cat"]).arg(Path::new("/var/lib/iwd").join(&profile)).stdin(Stdio::null()).output() else {
return Err(String::from("doas couldn't start"));
};
if output.status.success() {
return parse::psk_passphrase(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| String::from("Its profile holds no password"));
}
// doas says so when it wants a password; a missing file is the next name to try
if String::from_utf8_lossy(&output.stderr).contains("doas:") && !String::from_utf8_lossy(&output.stderr).contains("cat:") {
return Err(String::from("doas wants a password, so it can't be read"));
}
}
Err(String::from("No saved password for it"))
}
// leaves the network through iwctl, like joining
pub fn disconnect_wifi() {
let device = Links::read().wifi.device.clone();
let device = Links::read().wifi.device_name.clone();
if !device.is_empty() {
Bus::system().call(IWD, &device, STATION, "Disconnect", &[]);
let _ = Command::new("iwctl").args(["station", &device, "disconnect"]).stdin(Stdio::null()).output();
}
}
// a fresh list for the open Wi-Fi menu; iwd answers once the scan is done
// a fresh list for the Wi-Fi map, through iwctl; iwd's Scanning shows while it runs
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", &[]);
if wifi.service && wifi.powered && !wifi.access_point && !wifi.device_name.is_empty() {
let _ = Command::new("iwctl").args(["station", &wifi.device_name, "scan"]).stdin(Stdio::null()).output();
}
}
// looks for devices for a while through bluetoothctl, whose discovery ends when it does
pub fn scan_bluetooth() {
let (available, powered) = {
let bluetooth = Bluetooth::read();
(bluetooth.available(), bluetooth.powered())
};
if available && powered {
let _ = Command::new("bluetoothctl").args(["--timeout", "15", "scan", "on"]).stdin(Stdio::null()).output();
}
}
+7 -1
View File
@@ -8,6 +8,7 @@ use amane::{
Rectangle, Row, Service, Shadow, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
};
use crate::edge::{self, Edge, HIDE_H};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded};
use crate::theme;
@@ -42,6 +43,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let workspaces = Workspaces::read();
let hover = Hover::read();
// hidden until the pointer rests on the top edge (edge.rs)
let slide = edge::hidden_by(Edge::read().shown(&monitor.name), 0.0, HIDE_H);
// special workspaces (scratchpads) aren't places to switch to
let here: Vec<&Workspace> = workspaces
.list()
@@ -64,7 +68,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.namespace("magic-workspaces");
// empty and click-through rather than hidden: see the island's view for why
if here.is_empty() {
if here.is_empty() || slide <= -HIDE_H {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
}
@@ -118,6 +122,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
// clicks next to the pill reach the windows underneath
@@ -134,6 +139,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT })
.align_child(Start, Start)
.translate(0.0, slide)
.child(pill),
)
}