b14df3a435
- 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=
682 lines
24 KiB
Rust
682 lines
24 KiB
Rust
/*
|
|
* 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))
|
|
}
|