5d322b940d
appearance.surface.design picks the look (chips in the config window); each design's opacity, rim and light are settings. surface::shell now draws the island, idle pill, workspace and tray pills, tray menu, control centre and popup, so a design applies everywhere. On see-through glass faces fade with .opacity(). Shell text settings can list their allowed words (chips).
2188 lines
80 KiB
Rust
2188 lines
80 KiB
Rust
/*
|
|
* 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<String>,
|
|
pointer_on: Option<String>,
|
|
|
|
hover: HashMap<Spot, Animation>,
|
|
held: HashMap<Slider, (f32, Instant)>,
|
|
|
|
// a clicked tile, with what it said then: it shows "One moment" until that changes or the action is done
|
|
busy: HashMap<Tile, (Instant, String)>,
|
|
|
|
// the menu shown over the panel (kept while it shrinks away), whether it's wanted, and its own timeline
|
|
menu: Option<Menu>,
|
|
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<Instant>,
|
|
|
|
// 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<String>)>),
|
|
copied: Option<usize>,
|
|
}
|
|
|
|
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<f32> {
|
|
let clicked = self.scan_at.map(|at| at.elapsed().as_secs_f32());
|
|
|
|
match clicked {
|
|
Some(since) if since < map::ripple_s() || daemon => Some(since),
|
|
_ => daemon.then(map::clock),
|
|
}
|
|
}
|
|
}
|
|
|
|
// the bubble in a map: a click searches again, through iwctl or bluetoothctl, with rings running out; nothing while the radio is off
|
|
fn search_again(menu: Menu) {
|
|
let can = match menu {
|
|
Menu::Wifi => {
|
|
let links = Links::read();
|
|
|
|
links.wifi.service && links.wifi.powered && !links.wifi.access_point
|
|
}
|
|
_ => {
|
|
let bluetooth = Bluetooth::read();
|
|
|
|
bluetooth.available() && bluetooth.powered()
|
|
}
|
|
};
|
|
|
|
if !can {
|
|
return;
|
|
}
|
|
|
|
Control::write().scan_at = Some(Instant::now());
|
|
|
|
match menu {
|
|
Menu::Wifi => later(system::scan_wifi, None),
|
|
_ => later(system::scan_bluetooth, None),
|
|
}
|
|
}
|
|
|
|
// one spot lit at a time: amane matches hover targets by their place in the frame, so a vanished one never hears the pointer leave
|
|
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<usize>,
|
|
}
|
|
|
|
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<f32> {
|
|
let parts: Vec<f32> = [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<f32> {
|
|
let parts: Vec<f32> = [(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<f32> {
|
|
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<f32> {
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(2.0 * c::modules::pad() + parts.iter().sum::<f32>() + 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<String>) {
|
|
// 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 <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|wifi <join|password> SSID|wifi search|bluetooth <pair|connect> 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<Box<dyn Widget>>, f32) {
|
|
let theme = theme::now();
|
|
let wifi = &links.wifi;
|
|
|
|
let mut items: Vec<(Box<dyn Widget>, f32)> = Vec::new();
|
|
|
|
if links.wired.iter().any(|card| card.connected) {
|
|
items.push((Box::new(icon(Glyph::Ethernet, 15.0, theme.text)), 15.0));
|
|
}
|
|
|
|
if wifi.hotspot {
|
|
items.push((Box::new(icon(Glyph::Hotspot, 16.0, theme.accent)), 16.0));
|
|
} else if wifi.service && wifi.powered && !wifi.access_point {
|
|
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::<f32>() + 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<Box<dyn Widget>>, 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<dyn Widget>) -> 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<String>) -> 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<Action>,
|
|
|
|
// a link speed, small at the right of the title
|
|
badge: Option<String>,
|
|
}
|
|
|
|
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<Box<dyn Widget>> = 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<Menu>, 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<dyn Widget> = 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<Tile>) {
|
|
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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<dyn Widget> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>>,
|
|
width: f32,
|
|
height: f32,
|
|
body: Column,
|
|
}
|
|
|
|
// a menu's rows, counting their height as they're added
|
|
struct Body {
|
|
rows: Vec<Box<dyn Widget>>,
|
|
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<Box<dyn Widget>>, 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<Box<dyn Widget>>, 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<Box<dyn Widget>> = 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<Box<dyn Widget>>) -> 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<Box<dyn Widget>> = 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<dyn Widget>
|
|
});
|
|
|
|
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<Box<dyn Fn(Button)>> = match link {
|
|
_ if enterprise && !known => None,
|
|
Link::Connecting => None,
|
|
Link::Connected => Some(Box::new(move |button| match button {
|
|
Button::Right => prompt::reveal(&ssid),
|
|
_ => {
|
|
Control::write().pending = None;
|
|
|
|
later(system::disconnect_wifi, None);
|
|
}
|
|
})),
|
|
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<dyn Widget>
|
|
});
|
|
|
|
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<dyn Fn(Button)> = 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.<name>.");
|
|
}
|
|
|
|
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<dyn Widget> = 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<dyn Widget>,
|
|
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<dyn Widget> = 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())
|
|
}
|