/* * the control centre: a status pill in the top right corner of every monitor (wi-fi, * vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel * like macOS's: connection tiles, sound, the screen and keyboard light, the performance * mode with the battery, and the external gpu with the programs using it. it opens on * the island's timeline (island::phases): pill out, the empty shape grows, panel in */ use std::collections::HashMap; use std::thread; use std::time::{Duration, Instant}; use amane::{ 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, Shape, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes, }; use crate::config::appearance::{font, motion, overlays, text}; use crate::config::control as c; use crate::config::island::{animation as island_animation, pill as island_pill}; use crate::edge::{self, Edge}; use crate::glyph::{Glyph, icon}; use crate::island::{self, CLEAR, faded, mix, phases, veiled}; use crate::map::{self, Centre, Link, Node}; use crate::parse; use crate::prompt; use crate::settings; use crate::sound::Sound; use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; use crate::{surface, theme}; // every size and timing is a setting (config.rs: control.*); these follow from them fn menu_w() -> f32 { c::panel::width() - 2.0 * c::menus::pad() } fn inner_w() -> f32 { c::panel::width() - 2.0 * c::panel::pad() } // the four connection tiles, two by two, each with a round button fn tile_w() -> f32 { (inner_w() - c::panel::gap()) / 2.0 } // the rounded blocks below the tiles fn module_w() -> f32 { inner_w() - 2.0 * c::modules::pad() } // how wide the Wi-Fi and Bluetooth maps are on a monitor: from its right edge to the island's pill, less the gap pub fn map_width(monitor_w: f32) -> f32 { (monitor_w / 2.0 - island_pill::width() / 2.0 - c::maps::island_gap() - c::pill::right()).max(c::maps::min_width()) } #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Tile { Wifi, Bluetooth, Vpn, Hotspot, Ethernet, } #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Slider { Volume, Screen, // one output, by its pactl sink index Output(u32), } // the menus a tile or block opens #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Menu { Wifi, Bluetooth, Vpn, Sound, Ethernet, } // everything that lights up under the pointer #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Spot { Pill, Tile(Tile), Slider(Slider), Mute, Light(u32), Profile(usize), Sound, Back, Switch, Row(usize), Footer, // the panel's Settings button, which opens the config window Settings, // this desktop's bubble in a map Bubble, OutputMute(u32), } pub struct Control { // 0 is the pill, 1 the open panel open: Animation, expanded: bool, // the monitor the panel opened on (none: all, without a workspace list), and the one whose pill the pointer is on opened_on: Option, pointer_on: Option, hover: HashMap, held: HashMap, // a clicked tile, with what it said then: it shows "One moment" until that changes or the action is done busy: HashMap, // the menu shown over the panel (kept while it shrinks away), whether it's wanted, and its own timeline menu: Option, menu_up: bool, menu_open: Animation, // 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)>, // when the bubble in a map was clicked to search again: rings run out from it scan_at: Option, // the wired card the Ethernet menu shows, what it knew when it opened (label, value, what a click copies), and the row just copied ethernet: (String, Vec<(String, String, Option)>), copied: Option, } impl Service for Control { fn new() -> Self { Self { open: opening(), expanded: false, opened_on: None, pointer_on: None, hover: HashMap::new(), held: HashMap::new(), busy: HashMap::new(), menu: None, menu_up: false, menu_open: menu_opening(), inside_panel: false, inside_menu: false, pending: None, scan_at: None, ethernet: (String::new(), Vec::new()), copied: None, } } // changes only through input fn listen() {} } impl Control { pub fn expanded(&self) -> bool { 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) } fn slider(&self, which: Slider, actual: f32) -> f32 { match self.held.get(&which) { Some((value, at)) if at.elapsed() < Duration::from_millis(c::animation::held_ms()) => *value, _ => actual, } } fn busy(&self, tile: Tile, subtitle: &str) -> bool { self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(c::animation::busy_s())) } // 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(c::animation::pending_s())) } /* * 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 { 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 fn hovering(spot: Spot) -> impl Fn(bool) + 'static { move |inside| { let mut control = Control::write(); if inside && spot != Spot::Pill { for (other, highlight) in &mut control.hover { if *other != spot && *other != Spot::Pill { highlight.to(0.0); } } } control .hover .entry(spot) .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); } } // 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, profiles: bool, battery: bool, gpu_rows: Option, } impl Layout { fn largest() -> Layout { Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(c::modules::max_rows()) } } fn sound(&self) -> f32 { let extra: &[f32] = if self.extra_output { &[c::modules::title_height(), c::modules::slider_height()] } else { &[] }; module_height(&[&[c::modules::title_height(), c::modules::slider_height()], extra].concat()).unwrap_or(0.0) } fn display(&self) -> Option { let parts: Vec = [self.screen.then_some(c::modules::title_height()), self.screen.then_some(c::modules::slider_height()), self.keyboard.then_some(c::modules::keys_height())].into_iter().flatten().collect(); module_height(&parts) } fn performance(&self) -> Option { let parts: Vec = [(self.profiles || self.battery).then_some(c::modules::title_height()), self.profiles.then_some(c::modules::profile_height())].into_iter().flatten().collect(); module_height(&parts) } fn gpu(&self) -> Option { self.gpu_rows.map(|rows| 2.0 * c::modules::pad() + c::modules::title_height() + c::modules::vram_height() + rows.max(1) as f32 * c::modules::row_height() + 2.0 * c::modules::line_gap()) } fn height(&self) -> f32 { let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| c::panel::gap() + height).sum(); let tile_rows = if self.ethernet { 3.0 } else { 2.0 }; // the Settings button closes the panel 2.0 * c::panel::pad() + tile_rows * c::panel::tile_height() + (tile_rows - 1.0) * c::panel::gap() + modules + c::panel::gap() + c::panel::footer_height() } } fn module_height(parts: &[f32]) -> Option { if parts.is_empty() { return None; } Some(2.0 * c::modules::pad() + parts.iter().sum::() + c::modules::line_gap() * (parts.len() - 1) as f32) } // everything one frame of the open panel reads struct Scene<'a> { control: &'a Control, links: &'a Links, power: &'a Power, bluetooth: &'a Bluetooth, audio: &'a Audio, screen: &'a Brightness, gpu: &'a Gpu, hotspot: &'a str, extra_output: Option<&'a parse::Sink>, layout: Layout, } pub fn view(monitor: &Monitor) -> LayerWindow { theme::begin(); let control = Control::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); // 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, edge::hide_h()); // as wide as the widest thing it can become, a map menu; the shape grows leftwards from the corner // both windows are as wide as a map menu, the widest the panel becomes let map_w = map_width(monitor.width as f32); let window_w = c::pill::right() + map_w + island_pill::shadow(); let window = LayerWindow::new() .width(window_w) .height(island_pill::top_gap() + Layout::largest().height() + island_pill::shadow()) .anchor_vertical(Vertical::Top) .anchor_horizontal(Horizontal::Right) // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them .layer(Layer::Overlay) .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 <= -edge::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; let audio = open.then(Audio::read); let screen = open.then(Brightness::read); let gpu = open.then(Gpu::read); let settings = open.then(|| (c::services::hotspot(), c::services::extra_output())); let sound = open.then(Sound::read); let extra_output = match (&sound, &settings) { (Some(sound), Some((_, extra))) => sound.output_named(extra), _ => None, }; 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(), profiles: !power.profiles.is_empty(), battery: power.battery.is_some(), gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(c::modules::max_rows())), }; /* * a menu over the panel (its own window, menu_view): on the menu's timeline the * panel's content fades out first (0 to island_animation::pill_gone()), then the empty panel takes the * menu's height (to island_animation::shape_done()) while the menu grows out of its tile; settled, the * menu lies exactly on the panel */ let menu_t = if opened_here { control.menu_open.value() } else { 0.0 }; let menu_size = match (control.menu.filter(|_| menu_t > 0.0), &sound) { (Some(menu), Some(sound)) => { let content = menu_content(menu, map_w, &control, &links, &bluetooth, sound); Some((content.width, menu_height(&content))) } _ => None, }; let fade = if menu_size.is_some() { 1.0 - (menu_t / island_animation::pill_gone()).clamp(0.0, 1.0) } else { 1.0 }; let morph = ((menu_t - island_animation::pill_gone()) / (island_animation::shape_done() - island_animation::pill_gone())).clamp(0.0, 1.0); let morph = morph * morph * (3.0 - 2.0 * morph); let (full_w, full_h) = menu_size.map_or((c::panel::width(), layout.height()), |(menu_w, menu_h)| (mix(c::panel::width(), menu_w, morph), mix(layout.height(), menu_h, morph))); let (icons, pill_w) = pill_items(&links, &bluetooth, &power); let width = mix(pill_w + c::animation::hover_grow() * lift, full_w, grow); let height = mix(island_pill::height(), full_h, grow); let radius = mix(island_pill::height() / 2.0, c::panel::radius(), grow); let colors = theme::now().surfaces(); // at most one face at a time, each faded by a veil instead of a gpu layer let face = match (&audio, &screen, &gpu, &settings) { (Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => { let scene = Scene { control: &control, links: &links, power: &power, bluetooth: &bluetooth, audio, screen, gpu, hotspot, extra_output, layout, }; veiled(panel(&scene), width, height, radius, reveal * fade, &colors) } _ if pill > 0.0 => veiled(compact(icons, pill_w), width, height, radius, pill, &colors), // mid-grow, or under a menu: the empty shape alone _ => Rectangle::new().width(0.0).height(0.0), }; let tapped = monitor.name.clone(); let hovered = monitor.name.clone(); let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face); // the surface is surface.rs's, like the island's let shape = surface::shell(width, height, radius, &colors, lift, face) .on_click(move |_| tap(&tapped)) .on_hover(move |inside| { { let mut control = Control::write(); if inside { control.pointer_on = Some(hovered.clone()); } control.inside_panel = inside; } edge::over_bar(inside); hovering(Spot::Pill)(inside); // leaving a pill on another monitor leaves the open panel alone let panel_here = Control::read().opened_on.as_deref().is_none_or(|name| name == hovered); if !inside && panel_here { collapse_soon(); } }); let shape = if control.expanded && opened_here { shape } else { shape.cursor(Pointer) }; // clicks beside the shape reach the windows underneath let region = InputArea { x: (window_w - c::pill::right() - width).floor() as i32, y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: height.ceil() as i32, }; window.input_region(vec![region]).child( Rectangle::new() .width(Parent) .height(Parent) .padding(Padding { top: island_pill::top_gap(), right: c::pill::right(), bottom: 0.0, left: 0.0 }) .align_child(End, Start) .translate(0.0, slide) .child(shape), ) } fn opening() -> Animation { Animation::new(0.0).duration(Duration::from_millis(c::animation::open_ms())).easing(Easing::Linear) } fn tap(monitor: &str) { open_on(&mut Control::write(), Some(monitor.to_string())); } // keybinds open the panel on the focused monitor fn expand() { let focused = island::focused_monitor(); open_on(&mut Control::write(), focused); } fn open_on(control: &mut Control, monitor: Option) { // on another monitor: that one grows from its pill, this one shows its pill again if control.opened_on != monitor { control.open = opening(); control.opened_on = monitor; } // a menu that left with the panel (see collapse) stays behind no longer: the panel opens as itself if !control.menu_up { control.menu = None; control.menu_open = menu_opening(); } control.expanded = true; control.open.to(1.0); } /* * closes the panel, and an open menu with it: the menu's timeline stays where it is, * so the panel keeps the menu's size and shrinks from there straight into the pill, * while menu_view fades the menu's content out in place; nothing grows back to the * full panel on the way */ fn collapse() { let menu = { let mut control = Control::write(); control.expanded = false; control.open.to(0.0); // the panel's buttons are about to go, see hovering control.hover.retain(|spot, _| *spot == Spot::Pill); let menu = control.menu.filter(|_| control.menu_up); control.menu_up = false; control.inside_menu = false; menu }; if menu == Some(Menu::Bluetooth) && Bluetooth::read().available() { Bluetooth::stop_scan(); } } /* * the pointer left the panel or its menu: they're two windows, and going from one to * the other leaves the first before it enters the second, so wait a moment and close * only if it's over neither */ fn collapse_soon() { thread::spawn(|| { thread::sleep(Duration::from_millis(c::animation::collapse_ms())); let gone = { let control = Control::read(); !control.inside_panel && !control.inside_menu }; // 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(c::animation::menu_ms())).easing(Easing::Linear) } // grows the menu out of its tile, and asks for fresh lists while it's open fn open_menu(menu: Menu) { { let mut control = Control::write(); // another menu, or one that has already gone: grow again from its own tile if control.menu != Some(menu) || control.menu_open.value() == 0.0 { control.menu_open = menu_opening(); } control.menu = Some(menu); control.menu_up = true; control.menu_open.to(1.0); // it opens under the pointer that clicked its tile, which may not get an enter event until it moves control.inside_menu = true; } match menu { Menu::Wifi => { thread::spawn(system::scan_wifi); } Menu::Bluetooth if Bluetooth::read().available() => Bluetooth::start_scan(), _ => {} } } /* * a click on the Ethernet tile: the card's details are gathered off the drawing thread * (ip, sysfs), then the panel turns into them like into any menu */ fn open_ethernet() { thread::spawn(|| { let card = { let links = Links::read(); links.wired.iter().find(|card| card.connected).or(links.wired.first()).map(|card| card.name.clone()) }; let Some(card) = card else { return; }; let details = system::wired_details(&card); { let mut control = Control::write(); control.ethernet = (card, details); control.copied = None; } open_menu(Menu::Ethernet); }); } fn close_menu() { let menu = { let mut control = Control::write(); if !control.menu_up { return; } control.menu_up = false; control.menu_open.to(0.0); control.inside_menu = false; control.hover.retain(|spot, _| !matches!(spot, Spot::Back | Spot::Switch | Spot::Row(_) | Spot::Footer | Spot::OutputMute(_))); control.menu }; if menu == Some(Menu::Bluetooth) && Bluetooth::read().available() { Bluetooth::stop_scan(); } } /* * `amane ipc call control ...` for keybinds and tests: * open | close | toggle | menu wifi|bluetooth|vpn|sound|close | status */ pub fn command(arguments: &[String]) -> String { let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); match words.as_slice() { ["open"] => expand(), ["close"] => collapse(), ["toggle"] => { let expanded = Control::read().expanded; if expanded { collapse() } else { expand() } } ["menu", "close"] => close_menu(), ["menu", which] => { let menu = match *which { "wifi" => Menu::Wifi, "bluetooth" => Menu::Bluetooth, "vpn" => Menu::Vpn, "sound" => Menu::Sound, _ => return String::from("usage: control menu wifi|bluetooth|vpn|sound|close"), }; if !Control::read().expanded { expand(); } 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), // what a click on the Ethernet tile does ["ethernet"] => open_ethernet(), ["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 |prompt |wifi SSID|wifi search|bluetooth ADDRESS|bluetooth search|ethernet|status"), } String::from("ok") } fn status() -> String { let (expanded, menu) = { let control = Control::read(); let menu = match control.menu.filter(|_| control.menu_up) { Some(Menu::Wifi) => "wifi", Some(Menu::Bluetooth) => "bluetooth", Some(Menu::Vpn) => "vpn", Some(Menu::Sound) => "sound", Some(Menu::Ethernet) => "ethernet", None => "none", }; (control.expanded, menu) }; let (wifi, vpn) = { let links = Links::read(); let wifi = &links.wifi; let wifi = if !wifi.service { String::from("stopped") } else if wifi.hotspot { String::from("hotspot") } else if !wifi.powered { String::from("off") } else { wifi.state.clone() }; let vpn = match links.vpns.iter().map(|vpn| vpn.state).find(|state| *state != VpnState::Stopped) { Some(VpnState::Connected) => "connected", Some(_) => "connecting", None => "off", }; (wifi, vpn) }; 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} prompt={}", power.profile, prompt::status()) } // the pill: wi-fi (or the hotspot), vpn and bluetooth while their services run, and the battery // the pill's icons and how wide they are together; a stopped service shows nothing here fn pill_items(links: &Links, bluetooth: &Bluetooth, power: &Power) -> (Vec>, f32) { let theme = theme::now(); let wifi = &links.wifi; let mut items: Vec<(Box, 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 { items.push((Box::new(icon(Glyph::Wifi(if wifi.state == "connected" { wifi.bars } else { 0 }), 16.0, theme.text)), 16.0)); } else if wifi.service { // running, radio off items.push((Box::new(icon(Glyph::Wifi(0), 16.0, theme.dim)), 16.0)); } if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connected) { items.push((Box::new(icon(Glyph::Lock, 14.0, theme.accent)), 14.0)); } else if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connecting) { items.push((Box::new(icon(Glyph::Lock, 14.0, theme.text)), 14.0)); } if bluetooth.available() { let color = if !bluetooth.powered() { theme.dim } else if bluetooth.devices().iter().any(|device| device.connected()) { theme.accent } else { theme.text }; items.push((Box::new(icon(Glyph::Bluetooth, 15.0, color)), 15.0)); } if let Some(charge) = &power.battery { items.push((Box::new(battery(charge)), battery_width(charge))); } // nothing running and no battery: still something to click if items.is_empty() { items.push((Box::new(icon(Glyph::Gauge, 16.0, theme.dim)), 16.0)); } let width = items.iter().map(|(_, width)| width).sum::() + c::pill::gap() * (items.len() - 1) as f32 + 2.0 * c::pill::pad(); (items.into_iter().map(|(item, _)| item).collect(), width) } fn compact(items: Vec>, width: f32) -> Rectangle { Rectangle::new().width(width).height(island_pill::height()).align_child(Center, Center).child(Row::new(items).gap(c::pill::gap()).align(Center)) } // as wide as battery() draws: the bolt, the body and its nub, the percentage fn battery_width(charge: &Charge) -> f32 { let bolt = if charge.status == "Charging" { 11.0 + 5.0 } else { 0.0 }; bolt + 24.0 + 1.0 + 1.8 + 5.0 + label_width(&format!("{}%", charge.percent), 11.0, 0.0) } // a battery drawn from rectangles, filled to its charge, with a bolt while it charges fn battery(charge: &Charge) -> Row { let theme = theme::now(); let low = charge.percent <= 15 && charge.status == "Discharging"; let color = if low { overlays::low_battery() } else { theme.text }; let level = f32::from(charge.percent) / 100.0; let body = Rectangle::new() .width(24.0) .height(12.0) .radius(3.5) .border(1.2, faded(color, 140)) .padding(2.2) .align_child(Start, Center) .child(Rectangle::new().width((19.6 * level).max(2.0)).height(7.6).radius(1.6).fill(color)); let nub = Rectangle::new().width(1.8).height(4.5).radius(1.0).fill(faded(color, 140)); let mut parts: Vec> = Vec::new(); if charge.status == "Charging" { parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent))); } parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center))); parts.push(Box::new(Text::new(format!("{}%", charge.percent)).size(text::small()).color(theme.text))); Row::new(parts).gap(5.0).align(Center) } // the panel fn panel(scene: &Scene) -> Rectangle { let mut blocks: Vec> = vec![Box::new(tiles(scene)), Box::new(sound(scene))]; if let Some(height) = scene.layout.display() { blocks.push(Box::new(display(scene, height))); } if let Some(height) = scene.layout.performance() { blocks.push(Box::new(performance(scene, height))); } if let Some(height) = scene.layout.gpu() { blocks.push(Box::new(gpu(scene, height))); } blocks.push(Box::new(settings_button(scene.control))); let height = scene.layout.height(); Rectangle::new().width(c::panel::width()).height(height).padding(c::panel::pad()).child(Column::new(blocks).gap(c::panel::gap())) } // across the panel's foot: opens the config window and closes the panel fn settings_button(control: &Control) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Settings); Rectangle::new() .width(inner_w()) .height(c::panel::footer_height()) .radius(c::modules::radius()) .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(Spot::Settings)) .on_click(|_| { settings::open(); collapse(); }) .child(Row::new(children![icon(Glyph::Gear, 15.0, Color::blend(theme.dim, theme.text, lit)), Text::new("Settings").size(text::body()).color(theme.text)]).gap(8.0).align(Center)) } fn module(height: f32, content: Column) -> Rectangle { Rectangle::new().width(inner_w()).height(height).radius(c::modules::radius()).fill(overlays::chip()).padding(c::modules::pad()).child(content) } fn header(title: &str, right: Box) -> Row { Row::new(vec![Box::new(Text::new(title).size(text::body()).weight(Weight::Bold).color(theme::now().text)), right]) .justify(SpaceBetween) .align(Center) .width(module_w()) .height(c::modules::title_height()) } fn note(text: impl Into) -> Text { Text::new(text.into()).size(text::small()).color(theme::now().dim) } // the connection tiles #[derive(Clone, Copy, PartialEq)] enum Look { On, Off, // nothing to work with: no device, nothing set up Grey, // the service behind it isn't running: grey, with a strike through its icon Stopped, } // the strike over a stopped service's icon is overlays::strike(), neutral grey whatever the theme #[derive(Clone)] enum Action { Wifi, Bluetooth, Vpn(String), Hotspot, } struct State { look: Look, glyph: Glyph, subtitle: String, action: Option, // a link speed, small at the right of the title badge: Option, } fn tiles(scene: &Scene) -> Column { let row = |left: Rectangle, right: Rectangle| Row::new(children![left, right]).gap(c::panel::gap()); let control = scene.control; // Wi-Fi, Bluetooth and VPN open their menu; the hotspot just switches; a wired link only shows, across the panel let mut rows: Vec> = children![ row( 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_w()), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None, tile_w())), ]; if scene.layout.ethernet { // a click turns the panel into what there is to know about the card rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, inner_w()).cursor(Pointer).on_click(|_| open_ethernet()))); } Column::new(rows).gap(c::panel::gap()) } fn wifi_state(links: &Links) -> State { let wifi = &links.wifi; let (look, subtitle) = if !wifi.service { (Look::Stopped, String::from("Service off")) } else if wifi.device.is_empty() { (Look::Grey, String::from("No device")) } else if wifi.access_point { (Look::Off, String::from("Hotspot on")) } else if !wifi.powered { (Look::Off, String::from("Off")) } else { let subtitle = match wifi.state.as_str() { "connected" => wifi.network.clone(), "connecting" => String::from("Connecting…"), _ => String::from("Not connected"), }; (Look::On, subtitle) }; // 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, 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 { let (look, subtitle, action) = if bluetooth.available() && bluetooth.powered() { let connected: Vec<&str> = bluetooth.devices().iter().filter(|device| device.connected()).map(|device| device.name()).collect(); let subtitle = if connected.is_empty() { String::from("On") } else { connected.join(", ") }; (Look::On, subtitle, Some(Action::Bluetooth)) } else if bluetooth.available() { (Look::Off, String::from("Off"), Some(Action::Bluetooth)) } else if links.bluetooth_service { (Look::Stopped, String::from("Service off"), Some(Action::Bluetooth)) } else { (Look::Stopped, String::from("Not installed"), None) }; 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, badge: None }; }; let (look, subtitle) = match vpn.state { VpnState::Connected => (Look::On, vpn.name().to_string()), VpnState::Connecting => (Look::On, String::from("Connecting…")), VpnState::Stopped => (Look::Stopped, vpn.name().to_string()), }; State { look, glyph: Glyph::Lock, subtitle, action: Some(Action::Vpn(vpn.service.clone())), badge: None } } fn hotspot_state(scene: &Scene) -> State { let wifi = &scene.links.wifi; let (look, subtitle, action) = if !wifi.service { (Look::Stopped, String::from("Start Wi-Fi"), Some(Action::Hotspot)) } else if wifi.device.is_empty() { (Look::Grey, String::from("No device"), None) } else if wifi.hotspot { // the name until someone joins, then how many are on it let subtitle = if wifi.clients == 0 { wifi.hotspot_name.clone() } else { format!("{} connected", wifi.clients) }; (Look::On, subtitle, Some(Action::Hotspot)) } else if wifi.access_point { (Look::On, String::from("Starting…"), Some(Action::Hotspot)) } else if scene.hotspot.is_empty() { // control.services.hotspot in config.toml, see README "Hotspot setup" (Look::Grey, String::from("Not set up"), None) } else { (Look::Off, scene.hotspot.to_string(), Some(Action::Hotspot)) }; State { look, glyph: Glyph::Hotspot, subtitle, action, badge: None } } fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu: Option, width: f32) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Tile(which)); let busy = control.busy(which, &state.subtitle); let (round, mark) = match state.look { Look::On => (theme.accent, theme.on_accent), Look::Off => (overlays::chip_open(), theme.text), Look::Grey | Look::Stopped => (overlays::chip(), theme.dim), }; let subtitle = if busy { String::from("One moment…") } else { state.subtitle.clone() }; let words_w = width - 12.0 - 10.0 - c::panel::button() - 10.0; let mark = Rectangle::new().width(c::panel::button()).height(c::panel::button()).align_child(Center, Center).child(icon(state.glyph, 16.0, mark)); // a stopped service's icon is struck through, corner to corner, like a slashed sign let mark = if state.look == Look::Stopped { let strike = Canvas::new().width(c::panel::button()).height(c::panel::button()).shapes(shapes![Line::new().from(9.0, 9.0).to(c::panel::button() - 9.0, c::panel::button() - 9.0).stroke(1.8, overlays::strike()).cap(Cap::Round)]); Rectangle::new().width(c::panel::button()).height(c::panel::button()).child(Stack::new(children![mark, strike]).width(c::panel::button()).height(c::panel::button())) } else { 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(text::body()).weight(Weight::Bold).color(title_color).elide(); let title: Box = match &state.badge { Some(badge) => Box::new(Row::new(children![title, Text::new(badge.as_str()).size(text::tiny()).color(theme.accent)]).justify(SpaceBetween).align(Center).width(words_w)), None => Box::new(title), }; let tile = Rectangle::new() .width(width) .height(c::panel::tile_height()) .radius(c::modules::radius()) .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 12.0 }) .align_child(Start, Center) .scale(1.0 + 0.03 * lit) .on_hover(hovering(Spot::Tile(which))) .child( Row::new(children![ Rectangle::new().width(c::panel::button()).height(c::panel::button()).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).child(mark), Column::new(vec![title, Box::new(Text::new(subtitle).size(text::caption()).color(theme.dim).elide())]).gap(3.0).width(words_w), ]) .gap(10.0) .align(Center), ); // the whole tile opens its menu, where the switch is; tiles without one switch at once if let Some(menu) = menu { return tile.cursor(Pointer).on_click(move |_| open_menu(menu)); } match state.action { Some(action) if !busy => { let before = state.subtitle; tile.cursor(Pointer).on_click(move |_| act(which, before.clone(), action.clone())) } _ => tile, } } // runs a tile's action off the drawing thread, then looks again a few times while the daemons catch up fn act(tile: Tile, before: String, action: Action) { Control::write().busy.insert(tile, (Instant::now(), before)); later(move || { match action { Action::Wifi => system::toggle_wifi(), Action::Bluetooth => system::toggle_bluetooth(), Action::Vpn(service) => system::toggle_vpn(&service), Action::Hotspot => system::toggle_hotspot(), } }, Some(tile)); } // slow work on its own thread, then fresh state a few times while the daemons answer fn later(work: impl FnOnce() + Send + 'static, tile: Option) { thread::spawn(move || { work(); for wait in [200, 500, 800, 1500, 2500] { thread::sleep(Duration::from_millis(wait)); system::refresh(); } if let Some(tile) = tile { Control::write().busy.remove(&tile); } }); } // sound, screen and keyboard fn sound(scene: &Scene) -> Rectangle { let theme = theme::now(); let audio = scene.audio; let muted = audio.muted(); let value = scene.control.slider(Slider::Volume, f32::from(audio.volume().min(100)) / 100.0); let label = if muted { String::from("Muted") } else { format!("{}%", (value * 100.0).round()) }; // the value doubles as the mute button, as in the island's volume chip let lit = scene.control.lit(Spot::Mute); let mute = Rectangle::new() .width(label_width(&label, 11.0, 30.0)) .height(c::modules::title_height() + 2.0) .radius(6.0) .fill(Color::blend(if muted { overlays::chip_open() } else { island::CLEAR }, overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(Spot::Mute)) .on_click(|_| Audio::toggle_mute()) .child( Row::new(children![icon(if muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme.dim), Text::new(label).size(text::small()).color(theme.text)]) .gap(5.0) .align(Center), ); let glyph = if muted { Glyph::Muted } else { Glyph::Speaker }; let mut rows: Vec> = vec![ Box::new(header("Sound", Box::new(mute))), Box::new(slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, module_w(), set_volume)), ]; // a second output with its own slider (control.services.extra_output), e.g. the AV receiver if let Some(sink) = scene.extra_output { rows.push(Box::new(output_label(scene.control, sink, module_w()))); rows.push(Box::new(output_slider(scene.control, sink, module_w()))); } // the whole block opens the Sound menu; its slider and chips take their own clicks first let lit = scene.control.lit(Spot::Sound); module(scene.layout.sound(), Column::new(rows).gap(c::modules::line_gap())) .fill(Color::blend(overlays::chip(), overlays::chip_hover(), 0.5 * lit)) .cursor(Pointer) .on_hover(hovering(Spot::Sound)) .on_click(|_| open_menu(Menu::Sound)) } // an output's name and its volume, the volume being its mute button fn output_label(control: &Control, sink: &parse::Sink, width: f32) -> Row { let theme = theme::now(); let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 34); Row::new(children![Text::new(title).size(text::small()).color(theme.dim).elide(), output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(width).height(c::modules::title_height()) } fn output_mute(control: &Control, sink: &parse::Sink) -> Rectangle { let value = control.slider(Slider::Output(sink.index), f32::from(sink.volume.min(100)) / 100.0); let label = if sink.muted { String::from("Muted") } else { format!("{}%", (value * 100.0).round()) }; let lit = control.lit(Spot::OutputMute(sink.index)); let name = sink.name.clone(); Rectangle::new() .width(label_width(&label, 11.0, 14.0)) .height(c::modules::title_height()) .radius(6.0) .fill(Color::blend(if sink.muted { overlays::chip_open() } else { CLEAR }, overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(Spot::OutputMute(sink.index))) .on_click(move |_| Sound::toggle_output_mute(&name)) .child(Text::new(label).size(text::small()).color(theme::now().text)) } fn output_slider(control: &Control, sink: &parse::Sink, width: f32) -> Rectangle { let value = control.slider(Slider::Output(sink.index), f32::from(sink.volume.min(100)) / 100.0); let glyph = if sink.muted { Glyph::Muted } else if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; let name = sink.name.clone(); slider(control, Slider::Output(sink.index), if sink.muted { 0.0 } else { value }, glyph, width, move |value| { Sound::set_output_volume(&name, (value * 100.0).round() as u8); }) } fn display(scene: &Scene, height: f32) -> Rectangle { let theme = theme::now(); let mut rows: Vec> = Vec::new(); if scene.layout.screen { let value = scene.control.slider(Slider::Screen, f32::from(scene.screen.percent().min(100)) / 100.0); rows.push(Box::new(header("Display", Box::new(note(format!("{}%", (value * 100.0).round())))))); rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, module_w(), set_screen))); } if let Some(light) = &scene.power.keyboard { // off, then evenly up to the brightest; most keyboards have two or three steps let steps = light.max.min(3) + 1; let names: &[&str] = match steps { 2 => &["Off", "On"], 3 => &["Off", "Low", "High"], _ => &["Off", "Low", "Mid", "High"], }; let keys_w = 54.0; let mut keys: Vec> = Vec::new(); for (step, name) in names.iter().enumerate() { let level = step as u32 * light.max / (steps - 1).max(1); let chosen = light.level == level; let lit = scene.control.lit(Spot::Light(level)); keys.push(Box::new( segment(Row::new(children![label(name, 11.0, chosen)]), chosen, lit, keys_w, c::modules::keys_height()) .on_hover(hovering(Spot::Light(level))) .on_click(move |_| { thread::spawn(move || system::set_keyboard(level)); }), )); } rows.push(Box::new( Row::new(children![ Row::new(children![icon(Glyph::Keyboard, 15.0, theme.dim), Text::new("Keyboard").size(text::body()).color(theme.text)]).gap(8.0).align(Center), Row::new(keys).gap(6.0), ]) .justify(SpaceBetween) .align(Center) .width(module_w()) .height(c::modules::keys_height()), )); } module(height, Column::new(rows).gap(c::modules::line_gap())) } /* * a wide slider in the macOS manner: a rounded track, filled in the accent up to the * value, with its icon at the left end. drag or scroll it; the shown value is held * while the system catches up. it swallows its own clicks, so a press on it never * reaches a clickable block around it */ fn slider(control: &Control, which: Slider, value: f32, glyph: Glyph, width: f32, set: impl Fn(f32) + Clone + 'static) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Slider(which)); let fill_w = c::modules::slider_height() + (width - c::modules::slider_height()) * value.clamp(0.0, 1.0); let dragged = set.clone(); Rectangle::new() .width(width) .height(c::modules::slider_height()) .radius(Full) .fill(Color::blend(overlays::track(), overlays::chip_hover(), lit)) .cursor(Pointer) .on_hover(hovering(Spot::Slider(which))) .on_click(|_| {}) .on_drag(move |point| slide(which, (point.x - c::modules::slider_height() / 2.0) / (width - c::modules::slider_height()), &dragged)) .on_scroll(move |scroll| slide(which, value + if scroll.y < 0.0 { 0.05 } else { -0.05 }, &set)) .child( Stack::new(children![ Rectangle::new().width(fill_w).height(c::modules::slider_height()).radius(Full).fill(Color::blend(theme.accent, theme.flame, 0.35 * lit)), Rectangle::new().width(c::modules::slider_height()).height(c::modules::slider_height()).align_child(Center, Center).child(icon(glyph, 14.0, theme.on_accent)), ]) .width(width) .height(c::modules::slider_height()), ) } fn slide(which: Slider, value: f32, set: &impl Fn(f32)) { let value = value.clamp(0.0, 1.0); let unchanged = { let mut control = Control::write(); let unchanged = control.held.get(&which).is_some_and(|(held, at)| (held * 100.0).round() == (value * 100.0).round() && at.elapsed() < Duration::from_millis(c::animation::held_ms())); control.held.insert(which, (value, Instant::now())); unchanged }; if !unchanged { set(value); } } fn set_volume(value: f32) { if Audio::read().muted() { Audio::toggle_mute(); } Audio::set_volume((value * 100.0).round() as u8); } // the backlight file itself (logind refuses a shell without a seat), never below 1% fn set_screen(value: f32) { let percent = ((value * 100.0).round() as u8).max(1); thread::spawn(move || system::set_screen(percent)); } // a choice in a row of choices: the chosen one in the accent fn segment(content: Row, chosen: bool, lit: f32, width: f32, height: f32) -> Rectangle { let theme = theme::now(); let fill = if chosen { Color::blend(theme.accent, theme.flame, 0.5 * lit) } else { Color::blend(overlays::chip(), overlays::chip_hover(), lit) }; Rectangle::new().width(width).height(height).radius(10.0).fill(fill).align_child(Center, Center).cursor(Pointer).scale(1.0 + 0.04 * lit).child(content) } fn label(text: &str, size: f32, chosen: bool) -> Text { let theme = theme::now(); Text::new(text).size(size).weight(if chosen { Weight::Bold } else { Weight::Regular }).color(if chosen { theme.on_accent } else { theme.text }) } fn label_width(text: &str, size: f32, extra: f32) -> f32 { text.chars().count() as f32 * size * font::letter_width() + extra } // performance mode and battery fn performance(scene: &Scene, height: f32) -> Rectangle { let theme = theme::now(); let power = scene.power; let charge: Box = match &power.battery { Some(charge) => Box::new(Row::new(children![note(battery_words(charge)), battery(charge)]).gap(8.0).align(Center)), None => Box::new(note("")), }; let mut rows: Vec> = vec![Box::new(header("Performance", charge))]; if !power.profiles.is_empty() { let count = power.profiles.len() as f32; let width = (module_w() - 8.0 * (count - 1.0)) / count; let mut choices: Vec> = Vec::new(); for (index, profile) in power.profiles.iter().enumerate() { let chosen = *profile == power.profile; let lit = scene.control.lit(Spot::Profile(index)); let (name, glyph) = match profile.as_str() { "low-power" => ("Low power", Glyph::Leaf), "balanced" => ("Balanced", Glyph::Gauge), "performance" => ("Performance", Glyph::Bolt), other => (other, Glyph::Gauge), }; let mark = if chosen { theme.on_accent } else { theme.text }; let content = Row::new(children![Column::new(children![ Rectangle::new().width(width).height(16.0).align_child(Center, Center).child(icon(glyph, 15.0, mark)), Rectangle::new().width(width).height(14.0).align_child(Center, Center).child(label(name, 10.5, chosen)), ]) .gap(5.0)]); let picked = profile.clone(); choices.push(Box::new( segment(content, chosen, lit, width, c::modules::profile_height()).on_hover(hovering(Spot::Profile(index))).on_click(move |_| { let picked = picked.clone(); thread::spawn(move || system::set_profile(&picked)); }), )); } rows.push(Box::new(Row::new(choices).gap(8.0))); } module(height, Column::new(rows).gap(c::modules::line_gap())) } fn battery_words(charge: &Charge) -> String { let time = |minutes: u32| format!("{}:{:02}", minutes / 60, minutes % 60); match (charge.status.as_str(), charge.minutes) { ("Discharging", Some(minutes)) => format!("{} left", time(minutes)), ("Discharging", None) => String::from("On battery"), ("Charging", Some(minutes)) => format!("Full in {}", time(minutes)), ("Charging", None) => String::from("Charging"), _ => String::from("On power"), } } // the external gpu: its load, memory and temperature, and who uses it fn gpu(scene: &Scene, height: f32) -> Rectangle { let theme = theme::now(); let gpu = scene.gpu; let mut facts = vec![format!("{}%", gpu.busy)]; if let Some(celsius) = gpu.celsius { facts.push(format!("{celsius}°C")); } if let Some(watts) = gpu.watts { facts.push(format!("{watts:.0} W")); } let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center); let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(module_w()).height(c::modules::title_height()); let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 }; let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024)); let memory_w = label_width(&memory_label, 10.5, 4.0); let bar_w = module_w() - memory_w - 10.0; let memory = Row::new(children![ Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)), Text::new(memory_label).size(text::caption()).color(theme.dim), ]) .gap(10.0) .align(Center) .width(module_w()) .height(c::modules::vram_height()); let mut rows: Vec> = Vec::new(); let shown = if gpu.programs.len() > c::modules::max_rows() { c::modules::max_rows() - 1 } else { c::modules::max_rows() }; for program in gpu.programs.iter().take(shown) { let mut amount = size(program.vram_kib); if let Some(busy) = program.busy { amount = format!("{amount} · {busy}%"); } rows.push(Box::new( Row::new(children![ Text::new(program.name.as_str()).size(text::body()).color(theme.text).elide(), note(amount), ]) .justify(SpaceBetween) .align(Center) .width(module_w()) .height(c::modules::row_height()), )); } if gpu.programs.len() > c::modules::max_rows() { rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown))))); } if gpu.programs.is_empty() { rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note("No programs on it")))); } module(height, Column::new(children![top, memory, Column::new(rows)]).gap(c::modules::line_gap())) } // video memory in KiB, the way a person reads it fn size(kib: u64) -> String { match kib { 0..1024 => String::from("<1 MB"), 1024..1_048_576 => format!("{} MB", kib / 1024), _ => format!("{:.1} GB", kib as f64 / 1_048_576.0), } } /* * the menus: a window of their own per monitor, over the panel, so the panel never * re-lays out. a menu grows out of the tile or block that opened it (the island's * timeline again: the empty shape grows, then the content fades in) to the panel's * width and its own height, and the panel takes that size underneath (see view). * the back button or the pointer leaving both windows closes it */ pub fn menu_view(monitor: &Monitor) -> LayerWindow { theme::begin(); let control = Control::read(); 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); let timeline = control.menu_open.value(); // the whole panel is closing with this menu on it: the menu only fades its content, then leaves the shrinking to the panel let leaving = !control.menu_up && !control.expanded; let (_, _, panel_reveal) = phases(if on_here { control.open.value() } else { 0.0 }); // never hidden (see the island's view): 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(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); }; let links = Links::read(); let bluetooth = Bluetooth::read(); let sound = Sound::read(); let content = menu_content(menu, map_width(monitor.width as f32), &control, &links, &bluetooth, &sound); let menu_w = content.width; let menu_h = menu_height(&content); let window = window(c::pill::right() + menu_w + island_pill::shadow(), island_pill::top_gap() + menu_h + island_pill::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 colors = theme::now().surfaces(); let page = Rectangle::new() .width(menu_w) .height(menu_h) .padding(c::menus::pad()) .child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * c::menus::pad()), content.body]).gap(c::panel::gap())); // the veil is the panel's own backdrop, so the fade shows nothing but the content let shape = Rectangle::new() .width(menu_w) .height(menu_h) .on_hover(|inside| { Control::write().inside_menu = inside; if !inside { collapse_soon(); } }) .child(veiled(page, menu_w, menu_h, c::panel::radius(), reveal, &colors)); let region = InputArea { x: 0, y: island_pill::top_gap() as i32, width: menu_w.ceil() as i32, height: menu_h.ceil() as i32 }; window.input_region(vec![region]).child( Rectangle::new() .width(Parent) .height(Parent) .padding(Padding { top: island_pill::top_gap(), right: c::pill::right(), bottom: 0.0, left: 0.0 }) .align_child(End, Start) .child(shape), ) } // back to the panel: the pointer is over it now, where the menu was fn menu_back() { close_menu(); Control::write().inside_panel = true; } /* * 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, map_w: f32, control: &Control, links: &Links, bluetooth: &Bluetooth, sound: &Sound) -> MenuContent { match menu { Menu::Wifi => wifi_menu(control, links, map_w), Menu::Bluetooth => bluetooth_menu(control, bluetooth, links, map_w), Menu::Vpn => vpn_menu(control, links), Menu::Sound => sound_menu(control, sound), Menu::Ethernet => ethernet_menu(control), } } // as tall as its content, at most as tall as the windows fn menu_height(content: &MenuContent) -> f32 { (2.0 * c::menus::pad() + c::menus::header_height() + c::panel::gap() + content.height).min(Layout::largest().height()) } // 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, } // a menu's rows, counting their height as they're added struct Body { rows: Vec>, height: f32, gap: f32, } impl Body { fn new(gap: f32) -> Self { Self { rows: Vec::new(), height: 0.0, gap } } fn add(&mut self, widget: impl Widget + 'static, height: f32) { if !self.rows.is_empty() { self.height += self.gap; } self.rows.push(Box::new(widget)); self.height += height; } fn note(&mut self, text: &str) { self.add(Rectangle::new().width(menu_w()).height(c::menus::note_height()).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), c::menus::note_height()); } fn into_content(self, title: &'static str, right: Option>, width: f32) -> MenuContent { let gap = self.gap; MenuContent { title, right, width, height: self.height, body: Column::new(self.rows).gap(gap) } } } fn menu_header(control: &Control, title: &str, right: Option>, width: f32) -> Row { let theme = theme::now(); let lit = control.lit(Spot::Back); let back = Rectangle::new() .width(c::menus::header_height()) .height(c::menus::header_height()) .radius(Full) .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .scale(1.0 + 0.06 * lit) .on_hover(hovering(Spot::Back)) .on_click(|_| menu_back()) .child(icon(Glyph::Back, 13.0, theme.text)); let mut parts: Vec> = children![Row::new(children![back, Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)]).gap(10.0).align(Center)]; if let Some(right) = right { parts.push(right); } Row::new(parts).justify(SpaceBetween).align(Center).width(width).height(c::menus::header_height()) } // an on/off switch; grey: the service behind it isn't running, and switching on starts it fn switch(control: &Control, spot: Spot, on: bool, grey: bool) -> Rectangle { let theme = theme::now(); let lit = control.lit(spot); let track = if on { Color::blend(theme.accent, theme.flame, 0.3 * lit) } else if grey { Color::blend(overlays::chip(), overlays::chip_hover(), lit) } else { Color::blend(overlays::chip_open(), overlays::chip_hover(), lit) }; let knob = if grey { faded(Color::WHITE, 140) } else { Color::WHITE }; Rectangle::new() .width(42.0) .height(24.0) .radius(Full) .fill(track) .align_child(Start, Center) .cursor(Pointer) .on_hover(hovering(spot)) .child(Rectangle::new().width(20.0).height(20.0).radius(Full).fill(knob).translate(if on { 20.0 } else { 2.0 }, 0.0)) } // one line of a menu: a round icon, a name with a line under it, and something on the right fn menu_row(control: &Control, index: usize, glyph: Glyph, active: bool, title: &str, subtitle: &str, right: Option>) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Row(index)); let round = Rectangle::new() .width(30.0) .height(30.0) .radius(Full) .fill(if active { theme.accent } else { overlays::chip_open() }) .align_child(Center, Center) .child(icon(glyph, 15.0, if active { theme.on_accent } else { theme.text })); let right_w = if right.is_some() { 50.0 } else { 0.0 }; let words = Column::new(children![ Text::new(title).size(text::body()).weight(if active { Weight::Bold } else { Weight::Regular }).color(theme.text).elide(), Text::new(subtitle).size(text::caption()).color(theme.dim).elide(), ]) .gap(3.0) .width(menu_w() - 20.0 - 30.0 - 10.0 - right_w); let mut parts: Vec> = children![Row::new(children![round, words]).gap(10.0).align(Center)]; if let Some(right) = right { parts.push(right); } Rectangle::new() .width(menu_w()) .height(c::menus::row_height()) .radius(12.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .translate(3.0 * lit, 0.0) .on_hover(hovering(Spot::Row(index))) .child(Row::new(parts).justify(SpaceBetween).align(Center).width(menu_w() - 20.0).height(c::menus::row_height())) } // a quiet text button at the bottom of a menu fn footer(control: &Control, text: &str) -> Rectangle { let lit = control.lit(Spot::Footer); Rectangle::new() .width(menu_w()) .height(30.0) .radius(10.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .cursor(Pointer) .on_hover(hovering(Spot::Footer)) .child(Text::new(text).size(text::label()).color(Color::blend(theme::now().dim, theme::now().text, lit))) } fn wifi_menu(control: &Control, links: &Links, map_w: f32) -> MenuContent { let wifi = &links.wifi; // the tile shows "One moment" until the switch has done its work let before = wifi_state(links).subtitle; let right = (!wifi.service || !wifi.device.is_empty()).then(|| { let toggle = switch(control, Spot::Switch, wifi.service && wifi.powered && !wifi.access_point, !wifi.service) .on_click(move |_| act(Tile::Wifi, before.clone(), Action::Wifi)); Box::new(toggle) as Box }); let note = if !wifi.service { Some("iwd isn't running. The switch starts it.") } else if wifi.device.is_empty() { Some("iwd sees no Wi-Fi device.") } else if wifi.access_point { Some("The hotspot is using Wi-Fi; the switch stops it.") } else if !wifi.powered { Some("Wi-Fi is off.") } else if wifi.networks.is_empty() { Some("Looking for networks…") } else { 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(c::maps::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 { Link::Idle }; let detail = match link { 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 path = network.path.clone(); let ssid = network.name.clone(); let (known, secured, enterprise) = (network.known, network.secured, network.enterprise); // 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> = 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); } })), 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())); prompt::join(&ssid, secured && !known); } })), }; Node { glyph: Glyph::Wifi(network.bars), name: network.name.clone(), 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(c::panel::gap()); 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 * c::menus::pad(), c::maps::height(), centre, nodes), c::maps::height()); 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, map_w) } fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links, map_w: f32) -> MenuContent { let before = bluetooth_state(bluetooth, links).subtitle; let right = (bluetooth.available() || links.bluetooth_service).then(|| { let toggle = switch(control, Spot::Switch, bluetooth.available() && bluetooth.powered(), !bluetooth.available()) .on_click(move |_| act(Tile::Bluetooth, before.clone(), Action::Bluetooth)); Box::new(toggle) as Box }); let note = if !bluetooth.available() && !links.bluetooth_service { Some("bluez isn't installed: emerge net-wireless/bluez") } else if !bluetooth.available() { Some("bluetoothd isn't running. The switch starts it.") } else if !bluetooth.powered() { Some("Bluetooth is off.") } else if bluetooth.devices().is_empty() { Some(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." }) } else { None }; // by address, so each keeps its place around the bubble let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(c::maps::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 address = String::from(device.address()); let name = device.name().to_string(); let (connected, paired) = (device.connected(), device.paired()); // through bluetoothctl, whose agent asks in the popup and which says why it failed; leaving needs nothing of that let on_click: Box = Box::new(move |button| { if button != Button::Left { return; } if connected { Control::write().pending = None; Bluetooth::disconnect(&path); } else { Control::write().pending = Some((path.clone(), Instant::now())); prompt::bluetooth(&address, &name, !paired); } }); 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(); let mut body = Body::new(c::panel::gap()); 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 * c::menus::pad(), c::maps::height(), centre, nodes), c::maps::height()); body.into_content("Bluetooth", right, map_w) } fn vpn_menu(control: &Control, links: &Links) -> MenuContent { let before = vpn_state(links).subtitle; let mut body = Body::new(4.0); if links.vpns.is_empty() { body.note("No openvpn service: /etc/init.d/openvpn.."); } for (index, vpn) in links.vpns.iter().enumerate().take(c::menus::rows()) { let (on, subtitle) = match vpn.state { VpnState::Connected => (true, "Connected"), VpnState::Connecting => (true, "Connecting…"), VpnState::Stopped => (false, "Off"), }; let toggle = switch(control, Spot::Row(index), on, false); let service = vpn.service.clone(); let before = before.clone(); let row = menu_row(control, index, Glyph::Lock, vpn.state == VpnState::Connected, vpn.name(), subtitle, Some(Box::new(toggle))) .cursor(Pointer) .on_click(move |_| act(Tile::Vpn, before.clone(), Action::Vpn(service.clone()))); body.add(row, c::menus::row_height()); } body.into_content("VPN", None, c::panel::width()) } /* * the wired card as the panel shows it once clicked: label, value, and on a row with * something to copy a Copy chip (the whole row copies). wider than the panel, so an * IPv6 address with its prefix fits */ fn ethernet_menu(control: &Control) -> MenuContent { let theme = theme::now(); let (card, details) = &control.ethernet; let row_w = c::ethernet::width() - 2.0 * c::menus::pad(); let value_w = row_w - c::ethernet::label_width() - c::ethernet::copy_width() - 22.0; let mut body = Body::new(0.0); for (index, (label, value, copy_text)) in details.iter().enumerate() { let lit = if copy_text.is_some() { control.lit(Spot::Row(index)) } else { 0.0 }; let copied = control.copied == Some(index); let chip: Box = if copy_text.is_some() { let ink = if copied { theme.accent } else { Color::blend(theme.dim, theme.text, lit) }; Box::new(Rectangle::new().width(c::ethernet::copy_width()).height(20.0).radius(6.0).fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)).align_child(Center, Center).child(Text::new(if copied { "Copied" } else { "Copy" }).size(text::tiny()).color(ink))) } else { Box::new(Rectangle::new().width(c::ethernet::copy_width()).height(20.0)) }; let line = Rectangle::new() .width(row_w) .height(c::ethernet::detail_height()) .radius(8.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), 0.6 * lit)) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 8.0 }) .align_child(Start, Center) .child( Row::new(vec![ Box::new(Rectangle::new().width(c::ethernet::label_width()).height(c::ethernet::detail_height()).align_child(Start, Center).child(Text::new(label.as_str()).size(text::caption()).color(theme.dim))) as Box, Box::new(Rectangle::new().width(value_w).height(c::ethernet::detail_height()).align_child(Start, Center).child(Text::new(value.as_str()).size(text::label()).color(theme.text).elide())), chip, ]) .gap(6.0) .align(Center), ); match copy_text.clone() { Some(text) => body.add(line.cursor(Pointer).on_hover(hovering(Spot::Row(index))).on_click(move |_| prompt::copy(&text, move || Control::write().copied = Some(index))), c::ethernet::detail_height()), None => body.add(line, c::ethernet::detail_height()), } } let right: Box = Box::new(Text::new(card.as_str()).size(text::small()).color(theme.dim)); body.into_content("Ethernet", Some(right), c::ethernet::width()) } // every output with its own volume; a click on its name makes it the default fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { let theme = theme::now(); let mut body = Body::new(10.0); if sound.outputs().is_empty() { body.note("No outputs."); } for (index, sink) in sound.outputs().iter().take(c::menus::outputs()).enumerate() { let default = sink.name == sound.default_output(); let lit = control.lit(Spot::Row(index)); let radio = Rectangle::new().width(12.0).height(12.0).radius(Full); let radio = if default { radio.fill(theme.accent) } else { radio.border(1.5, theme.dim) }; let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 38); let name = Rectangle::new() .width(menu_w() - 70.0) .height(c::menus::output_height()) .radius(8.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 6.0 }) .align_child(Start, Center) .cursor(Pointer) .on_hover(hovering(Spot::Row(index))) .on_click({ let name = sink.name.clone(); move |_| Sound::set_default_output(&name) }) .child( Row::new(children![radio, Text::new(title).size(text::label()).weight(if default { Weight::Bold } else { Weight::Regular }).color(theme.text).elide()]) .gap(8.0) .align(Center), ); let line = Row::new(children![name, output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(menu_w()).height(c::menus::output_height()); body.add(Column::new(children![line, output_slider(control, sink, menu_w())]).gap(6.0), c::menus::output_height() + 6.0 + c::modules::slider_height()); } body.into_content("Sound", None, c::panel::width()) }