diff --git a/README.md b/README.md index 6f7b548..0a72bb0 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ The pill shows only what runs: Wi-Fi signal while iwd runs (dim while its radio 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 lists the networks in range (connected first, then by signal): click the connected one to disconnect, a saved or open one to join. A new network with a password has to be joined once with `iwctl` (the menu can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it. - - *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu scans while it's open and lists the devices: click to connect, disconnect, or pair (pairing works for devices without a PIN). + - *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). - *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. start|stop`. - *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup). -- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath, so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first. +- **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. - **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. @@ -116,9 +116,10 @@ src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava) 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, `control` IPC +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/glyph.rs every drawn icon +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 matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme diff --git a/src/control.rs b/src/control.rs index ae0ea87..8f3312f 100644 --- a/src/control.rs +++ b/src/control.rs @@ -17,6 +17,7 @@ use amane::{ 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::parse; use crate::sound::Sound; use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; @@ -38,6 +39,9 @@ const OUTPUTS: usize = 8; // the pill and the panel sit this far from the monitor's right edge 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; + // the pill is as wide as its icons, PILL_PAD on each side, PILL_GAP apart const PILL_PAD: f32 = 16.0; const PILL_GAP: f32 = 12.0; @@ -141,6 +145,9 @@ pub struct Control { // the pointer is over the panel's window or the menu's; it closes only once it has left both inside_panel: bool, inside_menu: bool, + + // 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)>, } impl Service for Control { @@ -158,6 +165,7 @@ impl Service for Control { menu_open: menu_opening(), inside_panel: false, inside_menu: false, + pending: None, } } @@ -184,6 +192,11 @@ impl Control { fn busy(&self, tile: Tile, subtitle: &str) -> bool { self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(10)) } + + // asked to connect less than 20 s ago + fn pending(&self, id: &str) -> bool { + self.pending.as_ref().is_some_and(|(pending, at)| pending == id && at.elapsed() < Duration::from_secs(20)) + } } // 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 @@ -290,7 +303,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow { 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); - let window_w = RIGHT + PANEL_W + SHADOW; + // as wide as the widest thing it can become, a map menu; the shape grows leftwards from the corner + let window_w = WINDOW_W; let window = LayerWindow::new() .width(window_w) @@ -333,21 +347,25 @@ pub fn view(monitor: &Monitor) -> LayerWindow { */ let menu_t = if opened_here { control.menu_open.value() } else { 0.0 }; - let menu_h = match (control.menu.filter(|_| menu_t > 0.0), &sound) { - (Some(menu), Some(sound)) => Some(menu_height(&menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0)), + 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; + + Some((content.width, menu_height(&content))) + } _ => None, }; - let fade = if menu_h.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 }; + let fade = if menu_size.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 }; let morph = ((menu_t - PILL_GONE) / (SHAPE_DONE - PILL_GONE)).clamp(0.0, 1.0); let morph = morph * morph * (3.0 - 2.0 * morph); - let full_h = menu_h.map_or(layout.height(), |menu_h| mix(layout.height(), menu_h, morph)); + let (full_w, full_h) = menu_size.map_or((PANEL_W, layout.height()), |(menu_w, menu_h)| (mix(PANEL_W, menu_w, morph), mix(layout.height(), menu_h, morph))); let (icons, pill_w) = pill_items(&links, &bluetooth, &power); - let width = mix(pill_w + PILL_HOVER_GROW * lift, PANEL_W, grow); + let width = mix(pill_w + PILL_HOVER_GROW * lift, full_w, grow); let height = mix(COMPACT_H, full_h, grow); let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow); @@ -1393,17 +1411,16 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { let control = Control::read(); - let window_w = RIGHT + PANEL_W + SHADOW; - let largest = Layout::LARGEST.height(); - - let window = LayerWindow::new() - .width(window_w) - .height(TOP_GAP + largest + SHADOW) - .anchor_vertical(Vertical::Top) - .anchor_horizontal(Horizontal::Right) - .layer(Layer::Overlay) - .space(Zone::Ignore) - .namespace("magic-control-menu"); + 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-control-menu") + }; let here = monitor.name.as_str(); let on_here = control.opened_on.as_deref().is_none_or(|name| name == here); @@ -1413,9 +1430,9 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { let leaving = !control.menu_up && !control.expanded; let (_, _, panel_reveal) = phases(if on_here { control.open.value() } else { 0.0 }); - // never hidden (see the island's view): an empty click-through window instead + // never hidden (see the island's view): a tiny, empty click-through window instead let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0 && (!leaving || panel_reveal > 0.0)) else { - return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); }; let links = Links::read(); @@ -1425,40 +1442,60 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra); + let menu_w = content.width; let menu_h = menu_height(&content); + // 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, PANEL_W, grow), mix(h0, menu_h, 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(PANEL_W) + .width(menu_w) .height(menu_h) .padding(MENU_PAD) - .child(Column::new(children![menu_header(&control, content.title, content.right), content.body]).gap(GAP)); + .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 shape = Rectangle::new() .width(width) .height(height) .radius(radius) .fill(backdrop(&colors, 255)) - .border(1.0, theme::now().outline) - // a shadow only while it grows: settled, it lies on the panel, which has its own glow - .shadow(Shadow::drop("#000000").opacity(0.45 * grow * (1.0 - settled)).blur(24.0).offset(0.0, 10.0)) + .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) .on_hover(|inside| { @@ -1471,7 +1508,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { .child(face); let region = InputArea { - x: (window_w - RIGHT - PANEL_W + x).floor() as i32, + 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, @@ -1483,7 +1520,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(PANEL_W).height(largest).align_child(Start, Start).child(shape)), + .child(Rectangle::new().width(menu_w).height(box_h).align_child(Start, Start).child(shape)), ) } @@ -1517,10 +1554,11 @@ fn menu_height(content: &MenuContent) -> f32 { (2.0 * MENU_PAD + MENU_HEADER_H + GAP + content.height).min(Layout::LARGEST.height()) } -// what a menu shows under its header +// what a menu shows under its header, and how wide the menu is (the panel's width, or a map's) struct MenuContent { title: &'static str, right: Option>, + width: f32, height: f32, body: Column, } @@ -1550,14 +1588,14 @@ impl Body { self.add(Rectangle::new().width(MENU_W).height(NOTE_H).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), NOTE_H); } - fn into_content(self, title: &'static str, right: Option>) -> MenuContent { + fn into_content(self, title: &'static str, right: Option>, width: f32) -> MenuContent { let gap = self.gap; - MenuContent { title, right, height: self.height, body: Column::new(self.rows).gap(gap) } + MenuContent { title, right, width, height: self.height, body: Column::new(self.rows).gap(gap) } } } -fn menu_header(control: &Control, title: &str, right: Option>) -> Row { +fn menu_header(control: &Control, title: &str, right: Option>, width: f32) -> Row { let theme = theme::now(); let lit = control.lit(Spot::Back); @@ -1580,7 +1618,7 @@ fn menu_header(control: &Control, title: &str, right: Option>) - parts.push(right); } - Row::new(parts).justify(SpaceBetween).align(Center).width(MENU_W).height(MENU_HEADER_H) + Row::new(parts).justify(SpaceBetween).align(Center).width(width).height(MENU_HEADER_H) } // an on/off switch; grey: the service behind it isn't running, and switching on starts it @@ -1680,59 +1718,86 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent { Box::new(toggle) as Box }); - let mut body = Body::new(4.0); - - if !wifi.service { - body.note("iwd isn't running. The switch starts it."); + let note = if !wifi.service { + Some("iwd isn't running. The switch starts it.") } else if wifi.device.is_empty() { - body.note("iwd sees no Wi-Fi device."); + Some("iwd sees no Wi-Fi device.") } else if wifi.access_point { - body.note("The hotspot is using Wi-Fi; the switch stops it."); + Some("The hotspot is using Wi-Fi; the switch stops it.") } else if !wifi.powered { - body.note("Wi-Fi is off."); + Some("Wi-Fi is off.") } else if wifi.networks.is_empty() { - body.note("Looking for networks…"); + Some("Looking for networks…") } else { - for (index, network) in wifi.networks.iter().take(MENU_ROWS).enumerate() { - let subtitle = if network.connected { - "Connected" - } else if network.known { - "Saved" - } else if network.secured { - "New · join it once with iwctl" + None + }; + + // by name, so each keeps its place around the bubble while signals change + let mut networks: Vec<_> = if note.is_none() { wifi.networks.iter().take(NODES).collect() } else { Vec::new() }; + + networks.sort_by(|one, other| one.name.cmp(&other.name)); + + let nodes = networks + .into_iter() + .enumerate() + .map(|(index, network)| { + let link = if network.connected { + Link::Connected + } else if network.connecting || control.pending(&network.path) { + Link::Connecting } else { - "Open" + Link::Idle }; - let lock = network.secured.then(|| Box::new(icon(Glyph::Lock, 12.0, theme::now().dim)) as Box); - - let row = menu_row(control, index, Glyph::Wifi(network.bars), network.connected, &network.name, subtitle, lock); + 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", + }; let joinable = network.known || !network.secured; + let path = network.path.clone(); - let row = if network.connected { - row.cursor(Pointer).on_click(|_| later(system::disconnect_wifi, None)) - } else if joinable { - let path = network.path.clone(); + let on_click: Option> = match link { + Link::Connected => Some(Box::new(|| { + Control::write().pending = None; + + later(system::disconnect_wifi, None); + })), + Link::Idle if joinable => Some(Box::new(move || { + Control::write().pending = Some((path.clone(), Instant::now())); - row.cursor(Pointer).on_click(move |_| { let path = path.clone(); later(move || system::connect_network(&path), None); - }) - } else { - row + })), + _ => None, }; - body.add(row, MENU_ROW_H); - } - } + Node { + glyph: Glyph::Wifi(network.bars), + name: network.name.clone(), + detail: String::from(detail), + nearness: f32::from(network.bars.saturating_sub(1)) / 2.0, + link, + lit: control.lit(Spot::Row(index)), + on_hover: Box::new(hovering(Spot::Row(index))), + on_click, + } + }) + .collect(); + + 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); if wifi.service { body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0); } - body.into_content("Wi-Fi", right) + body.into_content("Wi-Fi", right, MAP_W) } fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> MenuContent { @@ -1745,44 +1810,83 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me Box::new(toggle) as Box }); - let mut body = Body::new(4.0); - - if !bluetooth.available() && !links.bluetooth_service { - body.note("bluez isn't installed."); - body.note("emerge net-wireless/bluez, then switch it on here."); + let note = if !bluetooth.available() && !links.bluetooth_service { + Some("bluez isn't installed: emerge net-wireless/bluez") } else if !bluetooth.available() { - body.note("bluetoothd isn't running. The switch starts it."); + Some("bluetoothd isn't running. The switch starts it.") } else if !bluetooth.powered() { - body.note("Bluetooth is off."); + Some("Bluetooth is off.") } else if bluetooth.devices().is_empty() { - body.note(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." }); + Some(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." }) } else { - for (index, device) in bluetooth.devices().iter().take(MENU_ROWS).enumerate() { - let subtitle = match (device.connected(), device.paired(), device.battery()) { - (true, _, Some(battery)) => format!("Connected · {battery}%"), - (true, _, None) => String::from("Connected"), - (false, true, _) => String::from("Paired"), - (false, false, _) => String::from("Not paired · click to pair"), + None + }; + + // by address, so each keeps its place around the bubble + let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(NODES).collect() } else { Vec::new() }; + + devices.sort_by(|one, other| one.address().cmp(other.address())); + + let nodes = devices + .into_iter() + .enumerate() + .map(|(index, device)| { + let link = if device.connected() { + Link::Connected + } else if control.pending(device.path()) { + Link::Connecting + } else { + Link::Idle + }; + + let detail = match (link, device.battery()) { + (Link::Connected, Some(battery)) => format!("Connected · {battery}%"), + (Link::Connected, None) => String::from("Connected"), + (Link::Connecting, _) => String::from("Connecting…"), + _ if device.paired() => String::from("Paired"), + _ => String::from("Not paired · click to pair"), + }; + + // what it is, as far as bluez can tell + let glyph = match device.icon() { + kind if kind.starts_with("audio") => Glyph::Speaker, + "input-keyboard" => Glyph::Keyboard, + _ => Glyph::Bluetooth, }; let path = String::from(device.path()); let (connected, paired) = (device.connected(), device.paired()); - let row = menu_row(control, index, Glyph::Bluetooth, connected, device.name(), &subtitle, None).cursor(Pointer).on_click(move |_| { + let on_click: Box = Box::new(move || { if connected { + Control::write().pending = None; + Bluetooth::disconnect(&path); - } else if paired { - Bluetooth::connect(&path); } else { - Bluetooth::pair(&path); + Control::write().pending = Some((path.clone(), Instant::now())); + + if paired { Bluetooth::connect(&path) } else { Bluetooth::pair(&path) } } }); - body.add(row, MENU_ROW_H); - } - } + Node { + glyph, + name: device.name().to_string(), + detail, + nearness: if connected { 1.0 } else if paired { 0.6 } else { 0.1 }, + link, + lit: control.lit(Spot::Row(index)), + on_hover: Box::new(hovering(Spot::Row(index))), + on_click: Some(on_click), + } + }) + .collect(); - body.into_content("Bluetooth", right) + 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); + + body.into_content("Bluetooth", right, MAP_W) } fn vpn_menu(control: &Control, links: &Links) -> MenuContent { @@ -1813,7 +1917,7 @@ fn vpn_menu(control: &Control, links: &Links) -> MenuContent { body.add(row, MENU_ROW_H); } - body.into_content("VPN", None) + body.into_content("VPN", None, PANEL_W) } // every output with its own volume; a click on its name makes it the default @@ -1860,5 +1964,5 @@ fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { body.add(Column::new(children![line, output_slider(control, sink, MENU_W)]).gap(6.0), OUTPUT_H + 6.0 + SLIDER_H); } - body.into_content("Sound", None) + body.into_content("Sound", None, PANEL_W) } diff --git a/src/glyph.rs b/src/glyph.rs index 47dfba9..99f689c 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -26,6 +26,8 @@ pub enum Glyph { Gauge, Bolt, Chip, + // this machine, in the middle of the Wi-Fi and Bluetooth maps + Laptop, } pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { @@ -34,18 +36,30 @@ pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { // play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { - let s = size; + Canvas::new().width(size).height(size).shapes(glyph_shapes(glyph, size, color, weight, (0.0, 0.0))) +} - let triangle = |x0: f32, x1: f32| { - Path::new().move_to(x0, 0.14 * s).line_to(x1, 0.5 * s).line_to(x0, 0.86 * s).close().fill(color).stroke(weight, color) - }; +/* + * the glyph's shapes with its top left at `origin`, for drawing many icons into one + * Canvas: every Canvas widget costs the gpu on its own (7 small icon canvases took a + * map from 1.8 to 6.4 ms a frame), shapes inside one canvas cost next to nothing + */ +pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: (f32, f32)) -> Vec> { + let s = size; + let (ox, oy) = origin; + + // a fraction of the glyph's box, as a point on the canvas + let x = move |k: f32| ox + k * s; + let y = move |k: f32| oy + k * s; + + let triangle = |x0: f32, x1: f32| Path::new().move_to(x(x0), y(0.14)).line_to(x(x1), y(0.5)).line_to(x(x0), y(0.86)).close().fill(color).stroke(weight, color); let bar = |x0: f32, x1: f32| { Path::new() - .move_to(x0, 0.14 * s) - .line_to(x1, 0.14 * s) - .line_to(x1, 0.86 * s) - .line_to(x0, 0.86 * s) + .move_to(x(x0), y(0.14)) + .line_to(x(x1), y(0.14)) + .line_to(x(x1), y(0.86)) + .line_to(x(x0), y(0.86)) .close() .fill(color) .stroke(weight, color) @@ -53,12 +67,12 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { let speaker = || { Path::new() - .move_to(0.08 * s, 0.36 * s) - .line_to(0.28 * s, 0.36 * s) - .line_to(0.54 * s, 0.14 * s) - .line_to(0.54 * s, 0.86 * s) - .line_to(0.28 * s, 0.64 * s) - .line_to(0.08 * s, 0.64 * s) + .move_to(x(0.08), y(0.36)) + .line_to(x(0.28), y(0.36)) + .line_to(x(0.54), y(0.14)) + .line_to(x(0.54), y(0.86)) + .line_to(x(0.28), y(0.64)) + .line_to(x(0.08), y(0.64)) .close() .fill(color) }; @@ -67,73 +81,69 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { let faint = Color::rgba(color.red(), color.green(), color.blue(), color.alpha() / 3); - let line = |x0: f32, y0: f32, x1: f32, y1: f32| Line::new().from(x0 * s, y0 * s).to(x1 * s, y1 * s).stroke(thin, color).cap(Cap::Round); + let line = |x0: f32, y0: f32, x1: f32, y1: f32| Line::new().from(x(x0), y(y0)).to(x(x1), y(y1)).stroke(thin, color).cap(Cap::Round); - let shapes = match glyph { - Glyph::Play => shapes![triangle(0.2 * s, 0.9 * s)], - Glyph::Pause => shapes![bar(0.18 * s, 0.42 * s), bar(0.58 * s, 0.82 * s)], - Glyph::Next => shapes![triangle(0.08 * s, 0.7 * s), bar(0.74 * s, 0.9 * s)], - Glyph::Previous => shapes![triangle(0.92 * s, 0.3 * s), bar(0.1 * s, 0.26 * s)], - Glyph::Speaker => shapes![ - speaker(), - Arc::new().center(0.56 * s, 0.5 * s).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round), - ], + // a closed outline through the box's corners at these fractions + let frame = |x0: f32, y0: f32, x1: f32, y1: f32| Path::new().move_to(x(x0), y(y0)).line_to(x(x1), y(y0)).line_to(x(x1), y(y1)).line_to(x(x0), y(y1)).close(); + + match glyph { + Glyph::Play => shapes![triangle(0.2, 0.9)], + Glyph::Pause => shapes![bar(0.18, 0.42), bar(0.58, 0.82)], + Glyph::Next => shapes![triangle(0.08, 0.7), bar(0.74, 0.9)], + Glyph::Previous => shapes![triangle(0.92, 0.3), bar(0.1, 0.26)], + Glyph::Speaker => shapes![speaker(), Arc::new().center(x(0.56), y(0.5)).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round)], Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)], Glyph::Bluetooth => shapes![ Path::new() - .move_to(0.28 * s, 0.3 * s) - .line_to(0.72 * s, 0.68 * s) - .line_to(0.5 * s, 0.9 * s) - .line_to(0.5 * s, 0.1 * s) - .line_to(0.72 * s, 0.32 * s) - .line_to(0.28 * s, 0.7 * s) + .move_to(x(0.28), y(0.3)) + .line_to(x(0.72), y(0.68)) + .line_to(x(0.5), y(0.9)) + .line_to(x(0.5), y(0.1)) + .line_to(x(0.72), y(0.32)) + .line_to(x(0.28), y(0.7)) .stroke(thin, color) .cap(Cap::Round) ], - Glyph::Chevron => shapes![ - Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round) - ], - Glyph::Back => shapes![ - Path::new().move_to(0.64 * s, 0.14 * s).line_to(0.3 * s, 0.5 * s).line_to(0.64 * s, 0.86 * s).stroke(thin.max(1.6), color).cap(Cap::Round) - ], + Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)], + Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)], // an eighth note: round head, stem, a curved flag Glyph::Note => shapes![ - Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color), + Circle::new().center(x(0.32), y(0.74)).radius(0.17 * s).fill(color), line(0.47, 0.74, 0.47, 0.12), - Path::new().move_to(0.47 * s, 0.12 * s).quad_to(0.62 * s, 0.3 * s, 0.84 * s, 0.38 * s).stroke(thin, color).cap(Cap::Round), + Path::new().move_to(x(0.47), y(0.12)).quad_to(x(0.62), y(0.3), x(0.84), y(0.38)).stroke(thin, color).cap(Cap::Round), ], // a dot under three arcs fanning upwards Glyph::Wifi(lit) => { - let mut parts: Vec> = shapes![Circle::new().center(0.5 * s, 0.8 * s).radius(0.08 * s).fill(color)]; + let mut parts: Vec> = shapes![Circle::new().center(x(0.5), y(0.8)).radius(0.08 * s).fill(color)]; for (arc, radius) in [0.26, 0.46, 0.66].into_iter().enumerate() { let shade = if (arc as u8) < lit { color } else { faint }; - parts.push(Box::new(Arc::new().center(0.5 * s, 0.84 * s).radius(radius * s).start(-45.0).sweep(90.0).stroke(thin, shade).cap(Cap::Round))); + parts.push(Box::new(Arc::new().center(x(0.5), y(0.84)).radius(radius * s).start(-45.0).sweep(90.0).stroke(thin, shade).cap(Cap::Round))); } parts } // a dot sending out waves to both sides Glyph::Hotspot => { - let mut parts: Vec> = shapes![Circle::new().center(0.5 * s, 0.5 * s).radius(0.09 * s).fill(color)]; + let mut parts: Vec> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.09 * s).fill(color)]; for radius in [0.24, 0.42] { for start in [45.0, 225.0] { - parts.push(Box::new(Arc::new().center(0.5 * s, 0.5 * s).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round))); + parts.push(Box::new(Arc::new().center(x(0.5), y(0.5)).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round))); } } parts } Glyph::Lock => shapes![ - Arc::new().center(0.5 * s, 0.42 * s).radius(0.19 * s).start(-90.0).sweep(180.0).stroke(thin, color), + Arc::new().center(x(0.5), y(0.42)).radius(0.19 * s).start(-90.0).sweep(180.0).stroke(thin, color), line(0.31, 0.42, 0.31, 0.52), line(0.69, 0.42, 0.69, 0.52), - Path::new().move_to(0.18 * s, 0.5 * s).line_to(0.82 * s, 0.5 * s).line_to(0.82 * s, 0.9 * s).line_to(0.18 * s, 0.9 * s).close().fill(color), + frame(0.18, 0.5, 0.82, 0.9).fill(color), ], Glyph::Sun => { - let mut parts: Vec> = shapes![Circle::new().center(0.5 * s, 0.5 * s).radius(0.17 * s).fill(color)]; + let mut parts: Vec> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.17 * s).fill(color)]; for ray in 0..8 { let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_4).sin_cos(); @@ -144,47 +154,41 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { parts } Glyph::Keyboard => { - let mut parts: Vec> = shapes![ - Path::new().move_to(0.06 * s, 0.24 * s).line_to(0.94 * s, 0.24 * s).line_to(0.94 * s, 0.78 * s).line_to(0.06 * s, 0.78 * s).close().stroke(thin, color), - line(0.32, 0.64, 0.68, 0.64), - ]; + let mut parts: Vec> = shapes![frame(0.06, 0.24, 0.94, 0.78).stroke(thin, color), line(0.32, 0.64, 0.68, 0.64)]; - for (row, y) in [0.38, 0.5].into_iter().enumerate() { + for (row, key_y) in [0.38, 0.5].into_iter().enumerate() { for key in 0..4 { - let x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04; + let key_x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04; - parts.push(Box::new(Circle::new().center(x * s, y * s).radius(0.045 * s).fill(color))); + parts.push(Box::new(Circle::new().center(x(key_x), y(key_y)).radius(0.045 * s).fill(color))); } } parts } Glyph::Leaf => shapes![ - Path::new().move_to(0.18 * s, 0.84 * s).quad_to(0.14 * s, 0.2 * s, 0.84 * s, 0.16 * s).quad_to(0.84 * s, 0.82 * s, 0.18 * s, 0.84 * s).close().stroke(thin, color), + Path::new().move_to(x(0.18), y(0.84)).quad_to(x(0.14), y(0.2), x(0.84), y(0.16)).quad_to(x(0.84), y(0.82), x(0.18), y(0.84)).close().stroke(thin, color), line(0.18, 0.84, 0.56, 0.46), ], Glyph::Gauge => shapes![ - Arc::new().center(0.5 * s, 0.68 * s).radius(0.38 * s).start(-90.0).sweep(180.0).stroke(thin, color).cap(Cap::Round), + Arc::new().center(x(0.5), y(0.68)).radius(0.38 * s).start(-90.0).sweep(180.0).stroke(thin, color).cap(Cap::Round), line(0.5, 0.68, 0.68, 0.42), - Circle::new().center(0.5 * s, 0.68 * s).radius(0.07 * s).fill(color), + Circle::new().center(x(0.5), y(0.68)).radius(0.07 * s).fill(color), ], Glyph::Bolt => shapes![ Path::new() - .move_to(0.6 * s, 0.05 * s) - .line_to(0.2 * s, 0.56 * s) - .line_to(0.46 * s, 0.56 * s) - .line_to(0.38 * s, 0.95 * s) - .line_to(0.8 * s, 0.42 * s) - .line_to(0.54 * s, 0.42 * s) + .move_to(x(0.6), y(0.05)) + .line_to(x(0.2), y(0.56)) + .line_to(x(0.46), y(0.56)) + .line_to(x(0.38), y(0.95)) + .line_to(x(0.8), y(0.42)) + .line_to(x(0.54), y(0.42)) .close() .fill(color) ], // a die with pins on every side Glyph::Chip => { - let mut parts: Vec> = shapes![ - Path::new().move_to(0.24 * s, 0.24 * s).line_to(0.76 * s, 0.24 * s).line_to(0.76 * s, 0.76 * s).line_to(0.24 * s, 0.76 * s).close().stroke(thin, color), - Path::new().move_to(0.4 * s, 0.4 * s).line_to(0.6 * s, 0.4 * s).line_to(0.6 * s, 0.6 * s).line_to(0.4 * s, 0.6 * s).close().fill(color), - ]; + let mut parts: Vec> = shapes![frame(0.24, 0.24, 0.76, 0.76).stroke(thin, color), frame(0.4, 0.4, 0.6, 0.6).fill(color)]; for at in [0.4, 0.6] { parts.push(Box::new(line(at, 0.08, at, 0.22))); @@ -195,7 +199,7 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { parts } - }; - - Canvas::new().width(size).height(size).shapes(shapes) + // 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)], + } } diff --git a/src/main.rs b/src/main.rs index 382b709..c9269de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod art; mod control; mod glyph; mod island; +mod map; mod parse; mod sound; mod spectrum; diff --git a/src/map.rs b/src/map.rs new file mode 100644 index 0000000..e75f225 --- /dev/null +++ b/src/map.rs @@ -0,0 +1,244 @@ +/* + * the Wi-Fi and Bluetooth menus as a map: this desktop as a bubble in the middle and + * every network or device floating around it, nearer the stronger it is. a line ties + * each one to the bubble: grey and wobbling while it isn't connected, running out from + * the bubble towards it while it connects, solid (with a pulse travelling along it) + * once connected. it moves all the time, so the menu asks for frames while it shows + */ +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 crate::glyph::{Glyph, glyph_shapes}; +use crate::island::{CHIP_OPEN, faded}; +use crate::theme; + +// how wide the map menus are, against the panel's 360 +pub const MAP_W: f32 = 820.0; + +// the map's own height, under the menu's header +pub const AREA_H: f32 = 420.0; + +const BUBBLE: f32 = 40.0; +const NODE_R: f32 = 22.0; +const ICON: f32 = 18.0; +const LABEL_W: f32 = 140.0; +const LABEL_H: f32 = 30.0; + +// at most this many around the bubble +pub const NODES: usize = 12; + +// the wobbling line of what isn't connected +const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85); + +#[derive(Clone, Copy, PartialEq)] +pub enum Link { + Idle, + Connecting, + Connected, +} + +pub struct Node { + pub glyph: Glyph, + pub name: String, + pub detail: String, + + // 0 far out, 1 close to the bubble + pub nearness: f32, + pub link: Link, + + pub lit: f32, + pub on_hover: Box, + pub on_click: Option>, +} + +// seconds since the shell first drew a map: the clock every float and wobble runs on +fn clock() -> f32 { + static START: OnceLock = OnceLock::new(); + + START.get_or_init(Instant::now).elapsed().as_secs_f32() +} + +pub fn map(width: f32, height: f32, host: &str, nodes: Vec, scanning: bool, note: Option<&str>) -> 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; + + // 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); + + // and the one the nearest stay outside of, clear of the bubble, its ring and its name + let (inner_x, inner_y) = (BUBBLE + NODE_R + 110.0, BUBBLE + NODE_R + 58.0); + + let count = nodes.len().max(1) as f32; + + let mut idle = Path::new(); + let mut running = Path::new(); + let mut solid = Path::new(); + let mut glow = Path::new(); + let mut dots: Vec> = Vec::new(); + let mut discs: Vec> = Vec::new(); + + let mut floating: Vec> = Vec::new(); + + for (index, node) in nodes.into_iter().enumerate() { + let phase = index as f32; + + // evenly round, a quarter step past the top, so no one sits right on top of or under the bubble + let angle = -PI / 2.0 + TAU * (phase + 0.25) / count; + let reach = 1.0 - 0.4 * node.nearness.clamp(0.0, 1.0); + + // pushed out to the inner ellipse if the nearness took it inside + let (ex, ey) = (angle.cos() * rx * reach, angle.sin() * ry * reach); + let inside = ((ex / inner_x).powi(2) + (ey / inner_y).powi(2)).sqrt(); + let push = if inside < 1.0 { 1.0 / inside.max(0.01) } else { 1.0 }; + + // a slow float, each on its own rhythm + let drift = ((t * 0.55 + phase * 1.7).sin() * 6.0, (t * 0.45 + phase * 2.3).cos() * 5.0); + + let x = cx + ex * push + drift.0; + let y = cy + ey * push + drift.1; + + let (dx, dy) = (x - cx, y - cy); + let length = (dx * dx + dy * dy).sqrt().max(1.0); + let (ux, uy) = (dx / length, dy / length); + + let start = (cx + ux * bubble, cy + uy * bubble); + let span = (length - bubble - NODE_R).max(0.0); + + let point = |along: f32, swing: f32| (start.0 + ux * span * along - uy * swing, start.1 + uy * span * along + ux * swing); + + match node.link { + Link::Idle | Link::Connecting => { + // still at both ends, swinging in between, the swing running outwards + let amplitude = if node.link == Link::Idle { 5.0 } else { 2.5 }; + + let mut path = idle.move_to(start.0, start.1); + + for step in 1..=28 { + let along = step as f32 / 28.0; + let swing = amplitude * (along * PI).sin() * (along * 3.0 * TAU - t * 2.4 + phase).sin(); + let (px, py) = point(along, swing); + + path = path.line_to(px, py); + } + + idle = path; + + if node.link == Link::Connecting { + // the line runs out from the bubble to the device, over and over until it's connected + let reached = (t * 0.7 + phase * 0.13).fract(); + let reached = 1.0 - (1.0 - reached).powi(2); + let tip = point(reached, 0.0); + + running = running.move_to(start.0, start.1).line_to(tip.0, tip.1); + + dots.push(Box::new(Circle::new().center(tip.0, tip.1).radius(3.5).fill(theme.flame))); + } + } + Link::Connected => { + let end = point(1.0, 0.0); + + solid = solid.move_to(start.0, start.1).line_to(end.0, end.1); + glow = glow.move_to(start.0, start.1).line_to(end.0, end.1); + + // a pulse travelling out along it + let pulse = point((t * 0.45 + phase * 0.2).fract(), 0.0); + + dots.push(Box::new(Circle::new().center(pulse.0, pulse.1).radius(2.5).fill(theme.flame))); + } + } + + let (fill, mark) = match node.link { + Link::Connected => (theme.accent, theme.on_accent), + Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text), + Link::Idle => (CHIP_OPEN, theme.text), + }; + + let lit = node.lit; + + // its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line + let radius = NODE_R * (1.0 + 0.12 * lit); + + discs.push(Box::new(Circle::new().center(x, y).radius(radius).fill(Color::blend(fill, theme.flame, 0.2 * lit)))); + discs.push(Box::new(Circle::new().center(x, y).radius(radius).stroke(1.0, Color::blend(theme.outline, theme.accent, lit)))); + discs.extend(glyph_shapes(node.glyph, ICON, mark, 0.0, (x - ICON / 2.0, y - ICON / 2.0))); + + // what's left as a widget is only where the pointer finds it + 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()), + None => target, + }; + + floating.push(Box::new(target)); + + let bold = node.link == Link::Connected; + + // away from the bubble: above the ones above it, so a label never lies on its own line + let label_y = if y < cy - NODE_R { y - NODE_R - 5.0 - LABEL_H } else { y + NODE_R + 5.0 }; + + floating.push(Box::new( + Rectangle::new().width(LABEL_W).height(LABEL_H).align_child(Center, Start).translate(x - LABEL_W / 2.0, label_y).child( + Column::new(vec![ + Box::new(centred(Text::new(node.name).size(11.5).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)), + Box::new(centred(Text::new(node.detail).size(10.0).color(theme.dim).elide(), 13.0)), + ]) + .gap(2.0), + ), + )); + } + + let mut lines: Vec> = shapes![ + idle.stroke(1.5, IDLE_LINE).cap(Cap::Round), + glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round), + solid.stroke(2.4, theme.accent).cap(Cap::Round), + 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(); + + 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)))); + } + } + + 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.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> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))]; + + // this desktop's name under its bubble, and what to know under that + 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)), + )); + + 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)), + )); + } + + layers.extend(floating); + + Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height)) +} + +fn centred(text: Text, height: f32) -> Rectangle { + Rectangle::new().width(LABEL_W).height(height).align_child(Center, Center).child(text) +} diff --git a/src/system.rs b/src/system.rs index e063f3a..9987dca 100644 --- a/src/system.rs +++ b/src/system.rs @@ -49,6 +49,9 @@ pub struct Wifi { // the networks in range, the connected one first, then by signal pub networks: Vec, + + // iwd is scanning for networks + pub scanning: bool, } #[derive(Clone, PartialEq)] @@ -59,6 +62,9 @@ pub struct Network { pub secured: bool, pub known: bool, pub connected: bool, + + // iwd is joining it right now + pub connecting: bool, pub bars: u8, } @@ -89,6 +95,9 @@ pub struct Links { // bluez's openrc service is installed, so a click can start it pub bluetooth_service: bool, + + // this machine's name, for the bubble in the middle of the maps + pub host: String, } impl Service for Links { @@ -110,15 +119,16 @@ pub fn refresh() { let wifi = wifi(); let vpns = vpns(); let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists(); + let host = read(Path::new("/proc/sys/kernel/hostname")); let stale = { let links = Links::read(); - links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service + links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host }; if stale { - *Links::write() = Links { wifi, vpns, bluetooth_service }; + *Links::write() = Links { wifi, vpns, bluetooth_service, host }; } } @@ -150,6 +160,10 @@ fn wifi() -> Wifi { let connected = station.get("ConnectedNetwork").text(); let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text()); + // iwd names the network it's joining as connected already; the state tells the two apart + let state = station.get("State").text(); + let joined = state == "connected" || state == "roaming"; + // every network in range as [path, signal in hundredths of a dBm], strongest first let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing }; @@ -166,7 +180,8 @@ fn wifi() -> Wifi { name: found.get("Name").text().to_string(), secured: found.get("Type").text() != "open", known: !found.get("KnownNetwork").text().is_empty(), - connected: path == connected, + connected: path == connected && joined, + connecting: path == connected && !joined, bars: parse::wifi_bars(signal / 100.0), }) }) @@ -197,6 +212,7 @@ fn wifi() -> Wifi { hotspot_name: access_point.get("Name").text().to_string(), clients, networks, + scanning: station.get("Scanning").bool(), } }