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:
@@ -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
@@ -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
@@ -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())))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user