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