diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8f0f2..7bab87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,63 @@ The release workflow publishes the section that matches the tag as the GitHub release notes, so each version gets a heading of the form `## X.Y.Z (date)`. +## Unreleased + +- The window is redesigned around one design system, in the product + site's black, white and hairlines, set in Geist and Geist Mono. Colour + now means something: green for ready, amber for needs attention, red + for broken, and the logo's violet only for keyboard focus and + progress. Every text colour meets WCAG AA on every surface it sits on; + the old footer and captions did not. +- Tabs across the top: **Machines**, **Sharing** and **Settings** + (Command-1, 2 and Command-comma on a Mac; Ctrl elsewhere; Escape goes + back). The Sharing tab is new in the unified app: this machine's setup, + what it reports (Tailscale, streaming engine, network, Wake-on-LAN, + controllers), the devices paired with it, its options and its log. + Until now the unified window showed only a one-line summary and a + setup button; paired devices, the log and Stop service were + unreachable. +- The machine list shows each machine's state as a dot and one line, + with **Connect** (or **Wake and connect**) and a menu for Wake, Test + wake, Sleep, Restart, Shut down and Copy Tailscale address. Buttons + stay put and dim while a connection is in progress instead of + disappearing. This machine leads the list and opens its Sharing tab. +- Connecting says what is happening and what to expect; pairing shows + the PIN in boxes. A failure is titled "Couldn't connect to …" or + "… disconnected", with **Try again**, and a lighter profile is offered + only when the stream itself failed. The empty list, "looking", + Tailscale missing and Tailscale stopped each say what to do. +- Settings is grouped (Stream, Window and input, Power, Updates, Relay, + About) and the quality profile is one control: Recommended, Smooth, + Balanced, Sharp or Custom. Mouse capture is now a setting as well as a + toolbar choice. "Command acts as Ctrl" shows only on a Mac, and "Keep + this PC awake" only on Windows, where it does something. +- In a stream, the toolbar's menus look and behave the same, open + towards the window's middle, and fold into one **More** menu on narrow + windows. The host key is shown as keycaps (control option on a Mac, + Ctrl Alt elsewhere) when a stream starts and on the toolbar. The + connecting screen has a **Cancel** button, so leaving no longer needs + the host key. Stream settings and Stream performance are proper + panels with a close button; Escape closes Stream settings. +- Every menu, dropdown and panel fits the window down to its smallest + size (640×420). A menu opens upwards when there is more room above, + and scrolls, with its scroll bar showing, when neither side has room + for all of it; at the smallest size the stream toolbar's More menu cut + off "Command acts as Ctrl" and the PC's power items. Stream settings + and Stream performance stay below the toolbar, and Tab scrolls the + control it reaches into view. +- A notice's actions line up with its text: "Dismiss" sat 12 points to + the right of the words above it. +- A relayed path is amber, not red: it works, and Connection details + says how to get a direct one. +- The idle window no longer repaints on a timer (it redrew 2.5 times a + second forever), and the Tailscale check that could start a process + every two seconds on the UI thread now runs on its own thread. + Unpairing and installing the controller driver no longer block the + window. +- The app is about 470 KB smaller (11.7 MB for the macOS release + binary): Geist's five cuts replace Inter's three. + ## 4.0.2 (2026-09-22) - `panel.log` and `service.log` no longer grow without end. Both were diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49970ed..b0c6760 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,18 @@ keep their meaning. cargo test -p brolink-client -p brolink-host snapshots -- --ignored ``` -renders every screen to `target/ui-snapshots/*.png`. +renders every screen to `target/ui-snapshots/*.png`: each state of the +machine list, Settings, the Sharing page and the stream overlay at the +minimum window (640×420), a typical one (1280×800) and a large one +(1920×1200), at 1× and 2× (`client-lobby-1280x800@2x.png`); `*-full.png` +show whole scrolling pages. The window is built headless with default +settings, so nothing reads or writes your real configuration. The same +states run unignored, checking that nothing runs past the window and no +two controls overlap, at the minimum and the large size. + +Every colour, size and distance comes from `crates/ui/src/theme.rs`, and +every control from `crates/ui/src/widgets.rs`; a screen that needs +something new adds it there, with accesskit info and a focus ring. ## Tests against a real Sunshine diff --git a/NOTICE b/NOTICE index 74d9482..7f549d2 100644 --- a/NOTICE +++ b/NOTICE @@ -34,5 +34,9 @@ Used when present, never redistributed: Fonts: - Inter, SIL Open Font License 1.1, Rasmus Andersson and the Inter Project - Authors. crates/ui/assets/LICENSE-Inter.txt https://github.com/rsms/inter + Geist and Geist Mono, SIL Open Font License 1.1, The Geist Project + Authors (Vercel). Regular, Medium and SemiBold of Geist and Regular + and Medium of Geist Mono, unmodified, v1.7.2. + crates/ui/assets/LICENSE-Geist.txt https://github.com/vercel/geist-font + egui's fallback fonts: Hack (MIT), Ubuntu Light (Ubuntu Font Licence), + Noto Emoji (OFL 1.1) and emoji-icon-font (MIT), through epaint. diff --git a/README.md b/README.md index 7e5b597..ddd43c5 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,17 @@ machine answers only peers signed in to the same Tailscale account. ## How a session goes -1. Open BroLink. Every machine on your Tailscale account is listed with - what it can do right now. **Share this machine** sets up the streaming - engine so others can Connect here. +1. Open BroLink. **Machines** lists every machine on your Tailscale + account, with a dot and a line for what it can do right now. The + **Sharing** tab sets up the streaming engine so others can Connect here. 2. Click **Connect**. If a Windows PC is asleep, BroLink wakes it and waits. The first time, it pairs by itself: the PIN goes to BroLink on that machine over Tailscale, which enters it for you. 3. The desktop appears, full screen by default, and nothing else: the mouse is captured the moment you click the picture, as in a game, so - games that read raw mouse movement work. **Ctrl+Alt** is the host key, - as in a hypervisor: it frees the mouse and drops a toolbar over the + games that read raw mouse movement work. **Ctrl+Alt** (Control-Option + on a Mac) is the host key, as in a hypervisor: it frees the mouse and + drops a toolbar over the picture with the stream details, whether the path is direct or relayed, the mouse mode, a **Keys** menu for Ctrl+Alt+Del and friends and what the Command key does, stats, full screen, the PC's power menu, **Stream @@ -46,7 +47,7 @@ machine answers only peers signed in to the same Tailscale account. [latest release](https://github.com/MrBeldum/brolink/releases/latest) and unzip it. Run `brolink-host.exe`, or `install-host.ps1` for shortcuts and start-at-logon. -3. Click **Share this machine**. One administrator prompt installs the bundled +3. On the **Sharing** tab, click **Set up as administrator**. One administrator prompt installs the bundled streaming engine as a Windows service (if none is installed), gives BroLink a login to it, opens the control port to your tailnet only, turns Fast Startup off and arms the network card for Wake-on-LAN. @@ -76,8 +77,8 @@ open BroLink.app ``` The `xattr` step is needed for a browser download because the app is -ad-hoc signed rather than Developer-ID signed. Open BroLink and click -**Share this machine** so a Windows PC (or another Mac) can Connect here; +ad-hoc signed rather than Developer-ID signed. Open BroLink and set up +sharing on the **Sharing** tab so a Windows PC (or another Mac) can Connect here; macOS will ask for Screen Recording the first time. Details, including the toolbar and keyboard behaviour, in [docs/MACOS.md](docs/MACOS.md). @@ -106,7 +107,7 @@ from the Mac to every PC whose host reports an older version, over the same Tailscale-authenticated control API that can put the PC to sleep; the host verifies the digest, replaces its executable and restarts. That path updates only `brolink-host.exe`. The Windows zip on GitHub also contains -the pinned engine archive for first-time **Set up this PC**; a host update +the pinned engine archive for first-time setup; a host update does not install or migrate the engine. A PC that is asleep gets the host update the next time the Mac sees it. Nothing is downloaded on the PC, and no GitHub login is needed there. @@ -145,8 +146,8 @@ and to the PC's public address. | Same network as the PC | works | works if the board's firmware allows wake from power off | | Elsewhere | needs the PC's router to forward UDP 9 to the PC, or a Tailscale subnet router on that network | same, and the firmware condition | -**Test wake** in the PC menu on the Mac settles it for the network you are -on: it sends the packet while the PC is awake and asks BroLink Host whether +**Test waking it from this network** in a machine's **…** menu settles it for the network you are +on: it sends the packet while the PC is awake and asks BroLink there whether it arrived. Setup on the PC takes care of Windows' side (Fast Startup off, the adapter's wake keywords, wake allowed in power management). Wired Ethernet is strongly preferred; most Wi-Fi adapters cannot wake a PC. diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index a3192b6..d3009df 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -1,5 +1,5 @@ -//! The window: a list of PCs with a Connect button each, settings, and the -//! stream screen once connected. +//! The window: the machines on this tailnet, this machine's Sharing page, +//! settings, and the stream once connected. use crate::config::ClientConfig; #[cfg(test)] @@ -15,28 +15,42 @@ use crate::update; use brolink_core::api::PowerAction; use brolink_core::tailscale; use brolink_stream::Event; -use brolink_ui::{self as ui, Tone, PALETTE as P}; +use brolink_ui::{self as ui, column, space, Icon, Tone, PALETTE as P}; use eframe::egui; use parking_lot::Mutex; use semver::Version; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -const COLUMN_WIDTH: f32 = 860.0; - -const RELAY_NONE: &str = "No relay on this network. Streams use Tailscale's default relay if a direct path is not available. You can run your own relay with the deploy kit."; -const RELAY_READY: &str = "Using your network relay when a direct path is not available."; -const RELAY_OFFLINE: &str = "A relay is on this network but it is offline. Streams use Tailscale's default relay until it comes back."; -const RELAY_CHECKING: &str = "A relay is on this network. BroLink could not tell whether this device may use it — that is not a denial. Peer relay needs Tailscale 1.86 or later on every device."; -const RELAY_UNAVAILABLE: &str = "A relay is on this network but it is not available to this device yet. That is not a denial. Confirm the relay is configured, and paste this grant into the tailnet policy if it is missing."; +const RELAY_NONE: &str = "When two machines can't reach each other directly, Tailscale's relays carry the stream. For a shorter detour, run your own relay with BroLink's deploy kit."; +const RELAY_READY: &str = + "Your relay carries streams between machines that can't connect directly."; +const RELAY_OFFLINE: &str = "Your relay is offline. Until it is back, Tailscale's relays carry streams between machines that can't connect directly."; +const RELAY_CHECKING: &str = "A relay is on this tailnet, but BroLink couldn't confirm that this device may use it. That does not mean access is denied. Peer relays need Tailscale 1.86 or later on every device."; +const RELAY_UNAVAILABLE: &str = "A relay is on this tailnet but isn't available to this device yet. That does not mean access is denied: check that the relay is configured, and add this grant to the tailnet policy if it is missing."; #[cfg(test)] const RELAY_UNGRANTED: &str = "A relay node is online but this device is not granted access."; const RELAY_GRANT: &str = "{\n \"src\": [\"autogroup:member\"],\n \"dst\": [\"tag:relay\"],\n \"app\": {\n \"tailscale.com/cap/relay\": []\n }\n}"; const RELAY_DOCS: &str = "https://tailscale.com/docs/features/peer-relay"; +const DOWNLOAD_URL: &str = "https://github.com/MrBeldum/brolink/releases/latest"; +const SOURCE_URL: &str = "https://github.com/MrBeldum/brolink"; + +/// How long a one-line result stays on the machine list. +const NOTICE_FOR: Duration = Duration::from_secs(12); /// A one-line result, filled in by a worker thread. type Notice = Arc>>; +/// The window's pages, one per tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Page { + Machines, + /// This machine's sharing: setup, status, paired devices. + Sharing, + Settings, +} + /// Give the window the stream's proportions, keeping its width, so the /// picture fills it with no bar on any side. Only in a window: full screen /// is the screen's shape, which Match screen already is. @@ -58,6 +72,16 @@ fn window_size_for(current: egui::Vec2, aspect: f32) -> egui::Vec2 { egui::Vec2::new(width, (width / aspect).round().max(420.0)) } +/// "Command-," on a Mac, "Ctrl+," elsewhere: how a window shortcut is +/// written in a tooltip. +fn chord(key: &str) -> String { + if cfg!(target_os = "macos") { + format!("Command-{key}") + } else { + format!("Ctrl+{key}") + } +} + pub struct ClientApp { cfg: ClientConfig, dirty: bool, @@ -65,11 +89,11 @@ pub struct ClientApp { progress: Arc>, live: Arc>>, view: stream::View, - tailscale_ok: bool, - tailscale_checked: Instant, + /// Tailscale's CLI is on this machine. Probed off the UI thread: when it + /// is missing, looking for it runs a process. + tailscale_ok: Arc, brand: ui::Brand, - settings_open: bool, - notices_open: bool, + page: Page, fullscreen: bool, /// A restart or shutdown waits for a second click. confirm: Option<(String, PowerAction)>, @@ -84,6 +108,11 @@ pub struct ClientApp { /// Connect again as soon as the current stream has stopped. reconnect: Option, restart_capture: bool, + /// The last attempt reached the picture before it ended. + streamed: bool, + /// The last attempt failed in the stream itself (not while waking, + /// pairing or launching), where a lighter profile can help. + stream_failed: bool, /// What the PC said about its black picture, and whether a fix is on /// its way. Asked for once per stream, by the thread that answers. video_help: Arc>>, @@ -95,13 +124,15 @@ pub struct ClientApp { display_change: Option<((u32, u32), Instant)>, /// Present when this process also shares this machine. pub local: Option, + share_page: Option>, } impl ClientApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let discovery = Arc::new(Mutex::new(Discovery::default())); session::spawn_discovery(discovery.clone(), cc.egui_ctx.clone()); - let app = Self::with_shared(cc, discovery.clone(), Arc::default(), true); + let app = Self::with_shared(cc, discovery.clone(), Arc::default(), ClientConfig::load()); + spawn_tailscale_probe(app.tailscale_ok.clone(), cc.egui_ctx.clone()); update::spawn( app.updates.clone(), discovery, @@ -116,24 +147,22 @@ impl ClientApp { cc: &eframe::CreationContext<'_>, discovery: Arc>, progress: Arc>, - probe_tools: bool, + cfg: ClientConfig, ) -> Self { ui::apply(&cc.egui_ctx); if let Some(rs) = &cc.wgpu_render_state { crate::video::install(rs); } Self { - cfg: ClientConfig::load(), + cfg, dirty: false, discovery, progress, live: Arc::default(), view: stream::View::default(), - tailscale_ok: !probe_tools || tailscale::cli().is_some(), - tailscale_checked: Instant::now(), + tailscale_ok: Arc::new(AtomicBool::new(true)), brand: ui::Brand::new(&cc.egui_ctx), - settings_open: false, - notices_open: false, + page: Page::Machines, fullscreen: false, confirm: None, notice: None, @@ -144,21 +173,51 @@ impl ClientApp { last_pc: None, reconnect: None, restart_capture: false, + streamed: false, + stream_failed: false, video_help: Arc::default(), asked_about_video: false, handover: None, display_at_connect: (0, 0), display_change: None, local: None, + share_page: None, } } - /// Show "This machine" in the lobby and let the unified app run setup. - pub fn with_local(mut self, local: share::Slot) -> Self { + /// A window with no background threads and the given settings, for + /// rendering screens in tests (this crate's and the host's). + #[doc(hidden)] + pub fn headless( + cc: &eframe::CreationContext<'_>, + discovery: Discovery, + progress: Progress, + cfg: ClientConfig, + ) -> Self { + Self::with_shared( + cc, + Arc::new(Mutex::new(discovery)), + Arc::new(Mutex::new(progress)), + cfg, + ) + } + + /// Show this machine in the machine list and its Sharing page as a tab. + pub fn with_local(mut self, local: share::Slot, page: Box) -> Self { self.local = Some(local); + self.share_page = Some(page); self } + /// Switch tabs, as a click on one would. + pub fn open_page(&mut self, page: Page) { + self.page = if page == Page::Sharing && self.share_page.is_none() { + Page::Machines + } else { + page + }; + } + fn commit(&mut self) { if self.dirty { self.dirty = false; @@ -166,7 +225,7 @@ impl ClientApp { tracing::warn!("could not save settings: {e:#}"); self.notice = Some(( Tone::Danger, - format!("Could not save settings: {e}"), + format!("Couldn't save settings: {e}. They apply until BroLink quits."), Instant::now(), )); } @@ -190,6 +249,9 @@ impl ClientApp { }; self.ended_seen = false; self.offer_sleep = None; + self.streamed = false; + self.stream_failed = false; + self.confirm = None; self.last_pc = Some(pc.clone()); self.asked_about_video = false; *self.video_help.lock() = None; @@ -270,7 +332,7 @@ impl ClientApp { ) } } - Err(e) => (Tone::Danger, format!("Could not turn HDR off: {e}")), + Err(e) => (Tone::Danger, format!("Couldn't turn HDR off: {e}")), }; if let Some(h) = help.lock().as_mut() { h.busy = false; @@ -305,12 +367,12 @@ impl ClientApp { self.notify_later(move || match session::wake_test(&pc) { Ok(true) => ( Tone::Success, - format!("{} received the wake packet. Waking it from here will work.", pc.name), + format!("{} received the wake packet, so waking it from this network works.", pc.name), ), Ok(false) => ( - Tone::Danger, + Tone::Warning, format!( - "The wake packet did not reach {} from this network. Its router would have to forward UDP 9 to it.", + "The wake packet didn't reach {} from this network. Waking it from here needs its router to forward UDP port 9 to it.", pc.name ), ), @@ -347,6 +409,7 @@ impl ClientApp { Event::Connected => { prog.step = Step::Streaming; prog.since = Instant::now(); + self.streamed = true; if self.cfg.stream.fullscreen { fullscreen = Some(true); } else { @@ -355,9 +418,9 @@ impl ClientApp { self.view.stream_started(ctx, live, self.cfg.capture_mouse); if let Some(problem) = host_audio_problem(&self.discovery, &live.node_id) { self.view.toast( - Tone::Danger, + Tone::Warning, format!( - "{} has no sound to send: the PC reports “{problem}”. See the host window.", + "{} has no sound to send: it reports “{problem}”. Its Sharing page in BroLink says more.", live.pc ), ); @@ -365,21 +428,25 @@ impl ClientApp { } Event::Failed { stage, code } => { let cancelled = prog.cancel; + self.stream_failed = !cancelled; prog.step = Step::Ended { - error: (!cancelled) - .then_some(format!("Connecting failed at {stage} (code {code}).")), + error: (!cancelled).then_some(format!( + "The stream didn't start: the {stage} step failed (code {code}). The machine answered, so this is usually the network between you." + )), }; } Event::Terminated { code, message } => { let cancelled = prog.cancel; + let error = code != 0 && !cancelled; + self.stream_failed = error; prog.step = Step::Ended { - error: (code != 0 && !cancelled).then_some(message), + error: error.then_some(message), }; } Event::Poor(p) => self.view.set_poor(p), Event::NoAudio(e) => self .view - .toast(Tone::Danger, format!("No sound on this Mac: {e}.")), + .toast(Tone::Warning, format!("No sound on this machine: {e}.")), } } // A stop we asked for ends without a Terminated event. @@ -407,6 +474,55 @@ impl ClientApp { self.pending_notice = None; } } + + fn tailscale_installed(&self) -> bool { + self.tailscale_ok.load(Ordering::Relaxed) + } + + /// Window shortcuts: Command-1/2/3 (Ctrl elsewhere) switch tabs, + /// Command-, opens Settings, Escape goes back to the machine list or + /// drops a pending question. + fn shortcuts(&mut self, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let shortcut = |key| KeyboardShortcut::new(Modifiers::COMMAND, key); + let busy = ctx.memory(|m| m.any_popup_open()) || ctx.wants_keyboard_input(); + let (settings, one, two, three, escape) = ctx.input_mut(|i| { + ( + i.consume_shortcut(&shortcut(Key::Comma)), + i.consume_shortcut(&shortcut(Key::Num1)), + i.consume_shortcut(&shortcut(Key::Num2)), + i.consume_shortcut(&shortcut(Key::Num3)), + !busy && i.key_pressed(Key::Escape), + ) + }); + let has_sharing = self.share_page.is_some(); + if settings || three || (two && !has_sharing) { + self.page = Page::Settings; + } else if two { + self.page = Page::Sharing; + } else if one { + self.page = Page::Machines; + } + if escape { + if self.confirm.is_some() { + self.confirm = None; + } else { + self.page = Page::Machines; + } + } + } +} + +/// Look for Tailscale's CLI every few seconds on a thread of its own, and +/// repaint when the answer changes. +fn spawn_tailscale_probe(ok: Arc, ctx: egui::Context) { + std::thread::spawn(move || loop { + let found = tailscale::cli().is_some(); + if ok.swap(found, Ordering::Relaxed) != found { + ctx.request_repaint(); + } + std::thread::sleep(Duration::from_secs(if found { 30 } else { 3 })); + }); } impl eframe::App for ClientApp { @@ -439,6 +555,7 @@ impl eframe::App for ClientApp { cfg: &self.cfg, fullscreen: self.fullscreen, path: pc_now.map(|p| p.path.clone()), + os: pc_now.map(|p| p.os.clone()).unwrap_or_default(), old_host, handover: self.handover.as_ref().map(|h| h.status(&l.pc)), video_help: help, @@ -502,16 +619,15 @@ impl eframe::App for ClientApp { return; } - ctx.request_repaint_after(Duration::from_millis(400)); - if self.tailscale_checked.elapsed() > Duration::from_secs(2) { - self.tailscale_checked = Instant::now(); - self.tailscale_ok = tailscale::cli().is_some(); - } + // Nothing here repaints on a timer when idle: discovery, the + // updater, the local service and the connect worker each ask for a + // repaint when what they know changes. Only the clocks below do. let disc = self.discovery.lock().clone(); let prog = self.progress.lock().clone(); if let Some(pc) = self.reconnect.take() { if prog.active() { self.reconnect = Some(pc); + ctx.request_repaint_after(Duration::from_millis(250)); } else { // Fresh details if discovery has them; the saved ones otherwise. let fresh = disc @@ -538,290 +654,406 @@ impl eframe::App for ClientApp { } } if let Some((_, _, at)) = &self.notice { - if at.elapsed() > Duration::from_secs(12) { - self.notice = None; + match NOTICE_FOR.checked_sub(at.elapsed()) { + Some(left) => ctx.request_repaint_after(left), + None => self.notice = None, } } + if self.pending_notice.is_some() { + ctx.request_repaint_after(Duration::from_millis(250)); + } + self.shortcuts(ctx); ui::top_bar(ctx, "top", |ui| { - let (label, tone) = if !self.tailscale_ok || disc.error.is_some() { - ("Tailscale off", Tone::Danger) - } else if prog.active() { - ("Connecting", Tone::Accent) - } else { - ("Ready", Tone::Success) - }; - self.brand.header(ui, "BroLink", |ui| { - if ui::ghost_button( - ui, - if self.settings_open { - "Close settings" - } else { - "Settings" - }, - ) - .clicked() - { - self.settings_open = !self.settings_open; - } - ui::status_pill(ui, label, tone); + self.brand.lockup(ui, "BroLink"); + ui.add_space(space::XL); + let mut pages = vec![(Page::Machines, "Machines", chord("1"))]; + if self.share_page.is_some() { + pages.push((Page::Sharing, "Sharing", chord("2"))); + } + pages.push((Page::Settings, "Settings", chord(","))); + ui.scope(|ui| { + ui::tabs(ui, &pages, &mut self.page); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (tone, label) = if !self.tailscale_installed() { + (Tone::Danger, "Tailscale not installed") + } else if disc.error.is_some() { + (Tone::Danger, "Tailscale off") + } else if prog.active() { + (Tone::Accent, "Connecting") + } else if disc.refreshed.is_none() { + (Tone::Neutral, "Looking for machines") + } else { + (Tone::Success, "Tailscale on") + }; + ui::status_text(ui, tone, label); }); }); ui::bottom_bar(ctx, "bottom", |ui| { - // The button first, so a long login is cut rather than pushing - // it out of the window. - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut line = format!("v{}", env!("CARGO_PKG_VERSION")); - if !disc.login.is_empty() { - line.push_str(&format!(" · Tailscale as {}", disc.login)); - } - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.add(egui::Label::new(line).truncate()); - }); - }); + let mut line = format!("BroLink {}", env!("CARGO_PKG_VERSION")); + if !disc.login.is_empty() { + line.push_str(&format!(" · {}", disc.login)); + } + if let Some(v) = self.updates.lock().ready.clone() { + line.push_str(&format!(" · {v} installs after this session")); + } + ui.add(egui::Label::new(line).truncate()); }); egui::CentralPanel::default() .frame(egui::Frame::new().fill(P.bg)) .show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add_space(20.0); - ui::content_column(ui, COLUMN_WIDTH, |ui| { - ui.spacing_mut().item_spacing.y = 14.0; - self.tailscale_card(ui, &disc); - if let Some((tone, text, _)) = &self.notice { - ui::notice(ui, *tone, text); - } - if let Some((tone, text)) = self.updates.lock().notice.clone() { - ui::notice(ui, tone, &text); - } - if prog.active() { - self.session_card(ui, &prog); - } else if let Step::Ended { error } = &prog.step { - self.ended_card(ui, &prog, error.as_deref()); - } - if self.settings_open { - self.settings_card(ui, ctx, &prog); - self.relay_card(ui, &disc); - } else { - ui.add_space(8.0); - ui.label(egui::RichText::new("Your workspace, anywhere.").font(ui::theme::semibold(30.0)).color(P.text)); - ui::caption(ui, "Choose a machine to open its desktop. Your display and quality settings follow you."); - ui.add_space(14.0); - self.pcs_card(ui, ctx, &disc, &prog); - ui::titled_card(ui, "Next session", None, |ui| { - let settings = path::effective(&self.cfg.stream, &path::Path::default()); - let (w, h) = settings.resolution.pixels(Self::native_pixels(ctx)); - ui.horizontal_wrapped(|ui| { - ui::status_pill(ui, &format!("{w} × {h}"), Tone::Info); - ui::status_pill(ui, &format!("{} fps", settings.fps), Tone::Neutral); - ui::status_pill(ui, &format!("{} Mbps target", settings.bitrate_kbps / 1000), Tone::Neutral); - if ui::ghost_button(ui, "Configure stream").clicked() { self.settings_open = true; } - }); - }); - if !path_warnings(&disc).is_empty() { - egui::CollapsingHeader::new("Connection details").show(ui, |ui| self.path_notices(ui, &disc)); + egui::ScrollArea::vertical() + .id_salt(("page", self.page as u8)) + .show(ui, |ui| { + ui.add_space(space::XL); + let width = match self.page { + Page::Machines => column::WIDE, + _ => column::NARROW, + }; + ui::content_column(ui, width, |ui| { + ui.spacing_mut().item_spacing.y = space::LG; + match self.page { + Page::Machines => self.machines_page(ui, ctx, &disc, &prog), + Page::Sharing => match self.share_page.as_mut() { + Some(page) => page.show(ui), + None => self.page = Page::Machines, + }, + Page::Settings => self.settings_page(ui, ctx, &disc, &prog), } - self.key_expiry_notices(ui, &disc); - } - ui.add_space(10.0); + ui.add_space(space::XL); + }); }); - }); }); self.commit(); } } impl ClientApp { - fn tailscale_card(&mut self, ui: &mut egui::Ui, disc: &Discovery) { - let problem = if !self.tailscale_ok { - Some("Tailscale is not installed.".to_string()) - } else { - disc.error.as_ref().map(|e| format!("Tailscale: {e}.")) + // ----------------------------------------------------------------------- + // Machines + // ----------------------------------------------------------------------- + + fn machines_page( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + disc: &Discovery, + prog: &Progress, + ) { + let online = disc.pcs.iter().filter(|p| p.online).count(); + let summary = match disc.pcs.len() { + 0 => String::new(), + 1 => format!("1 machine · {online} online"), + n => format!("{n} machines · {online} online"), }; - let Some(problem) = problem else { return }; - ui::toned_card(ui, Tone::Danger, |ui| { - ui::heading( + ui::page_header(ui, "Machines", None, |ui| { + ui::small_print(ui, summary); + }); + self.tailscale_banner(ui, disc); + if let Some((tone, text, _)) = self.notice.clone() { + ui::notice(ui, tone, &text); + } + if let Some((tone, text)) = self.updates.lock().notice.clone() { + ui::notice(ui, tone, &text); + } + for text in key_expiry_warnings(disc).iter().filter(|t| urgent(t)) { + ui::notice(ui, Tone::Danger, text); + } + if prog.active() { + self.session_card(ui, prog); + } else if let Step::Ended { error } = &prog.step { + self.ended_card(ui, prog, error.as_deref()); + } + self.confirm_banner(ui, disc); + self.machine_list(ui, ctx, disc, prog); + self.next_stream(ui, ctx); + self.details(ui, disc); + } + + fn tailscale_banner(&mut self, ui: &mut egui::Ui, disc: &Discovery) { + if !self.tailscale_installed() { + ui::banner( ui, - "Tailscale is needed", - Some("It connects this machine to the others on your account from anywhere and confirms they are yours."), + Tone::Danger, + "Tailscale isn't installed", + Some("BroLink finds your machines and connects to them through Tailscale. Install it and sign in with the account your other machines use; they appear here within seconds."), + |ui| { + if ui::primary_button(ui, "Get Tailscale").clicked() { + let url = if cfg!(windows) { + "https://tailscale.com/download/windows" + } else if cfg!(target_os = "linux") { + "https://tailscale.com/download/linux" + } else { + "https://tailscale.com/download/mac" + }; + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + }, ); - ui::setting_row(ui, "Tailscale", Some(&problem), |ui| { - if ui::primary_button(ui, "Get Tailscale").clicked() { - let url = if cfg!(windows) { - "https://tailscale.com/download/windows" - } else if cfg!(target_os = "linux") { - "https://tailscale.com/download/linux" - } else { - "https://tailscale.com/download/mac" - }; - ui.ctx().open_url(egui::OpenUrl::new_tab(url)); - } - }); - }); + } else if let Some(e) = &disc.error { + ui::banner( + ui, + Tone::Danger, + "Tailscale isn't connected", + Some(&format!( + "{}. Open Tailscale and sign in with the account your other machines use. Machines seen before are listed, but can't be reached until then.", + sentence(e) + )), + |_| {}, + ); + } } - fn this_machine_row(&mut self, ui: &mut egui::Ui) { - let Some(slot) = self.local.clone() else { + fn confirm_banner(&mut self, ui: &mut egui::Ui, disc: &Discovery) { + let Some((name, action)) = self.confirm.clone() else { return; }; + let pc = disc.pcs.iter().find(|p| p.name == name).cloned(); + ui::banner( + ui, + Tone::Danger, + &format!("{} {name}?", action.label()), + Some("Programs there close without asking, and anything unsaved is lost."), + |ui| { + if ui::destructive_button(ui, action.label()).clicked() { + if let Some(ip) = pc.as_ref().and_then(|p| p.ip) { + self.power(ip, &name, action); + } + self.confirm = None; + } + if ui::ghost_button(ui, "Cancel").clicked() { + self.confirm = None; + } + }, + ); + } + + fn machine_list( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + disc: &Discovery, + prog: &Progress, + ) { + ui::group(ui, |ui| { + let mut first = true; + if let Some(slot) = self.local.clone() { + if self.share_page.is_some() { + self.this_machine_row(ui, &slot); + first = false; + } + } + if disc.pcs.is_empty() { + if !first { + ui::row_separator(ui); + } + self.empty_list(ui, disc); + return; + } + for pc in &disc.pcs { + if !first { + ui::row_separator(ui); + } + first = false; + let (tone, detail) = describe(pc); + ui::list_row(ui, Some(tone), &pc.name, &detail, |ui| { + if pc.can_stream() && !pc.remembered { + let label = if pc.online { + "Connect" + } else { + "Wake and connect" + }; + let clicked = ui + .add_enabled_ui(!prog.active(), |ui| { + ui::secondary_button(ui, label) + .on_disabled_hover_text("Another connection is in progress.") + }) + .inner + .clicked(); + if clicked { + self.connect(ctx, pc); + } + } + self.row_menu(ui, pc); + }); + } + }); + } + + fn empty_list(&mut self, ui: &mut egui::Ui, disc: &Discovery) { + ui.add_space(space::LG); + if disc.error.is_some() || !self.tailscale_installed() { + ui::muted(ui, "Your machines appear here once Tailscale is connected."); + } else if disc.refreshed.is_none() { + ui::empty_state(ui, "Looking for machines on your tailnet…", true); + } else { + ui::strong(ui, "No other machines yet"); + ui::caption( + ui, + "Install BroLink on another computer and sign in to Tailscale there with the same account. It appears here within a few seconds.", + ); + ui.add_space(space::XS); + if ui::link(ui, "Download BroLink").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(DOWNLOAD_URL)); + } + } + ui.add_space(space::LG); + } + + /// The first row of the list: this machine, and where its sharing is. + fn this_machine_row(&mut self, ui: &mut egui::Ui, slot: &share::Slot) { let g = slot.lock(); let running = g.setup_running; - let result = g.setup_result.clone(); let status = g.status.clone(); drop(g); - ui::row_separator(ui); + let noun = this_noun(status.as_ref().map(|s| s.os.as_str()).unwrap_or_default()); let name = status .as_ref() - .map(|s| s.name.as_str()) + .map(|s| s.name.clone()) .filter(|n| !n.is_empty()) - .unwrap_or("This machine"); - let detail = match &status { - Some(s) if s.streamer.running && s.streamer.api_ok => { + .unwrap_or_else(|| noun.to_string()); + let shared = status + .as_ref() + .is_some_and(|s| s.streamer.running && s.streamer.api_ok); + let (tone, detail) = match &status { + None => ( + Tone::Neutral, + "Starting BroLink's background service…".to_string(), + ), + Some(_) if running => (Tone::Accent, format!("{noun} · Setting up sharing…")), + Some(s) if shared && s.setup.is_empty() => ( + Tone::Success, format!( - "Sharing · {}{}", - os_label(&s.os), + "{noun} · Shared{}", s.tailscale_ip .as_deref() - .map(|ip| format!(" · {ip}")) + .map(|ip| format!(" at {ip}")) .unwrap_or_default() - ) - } - Some(s) if s.streamer.installed => { - format!( - "{} · streaming engine installed, not sharing yet", - os_label(&s.os) - ) - } - Some(s) => format!("{} · set up sharing so others can connect", os_label(&s.os)), - None => "Starting the local BroLink service…".into(), + ), + ), + Some(_) if shared => ( + Tone::Warning, + format!("{noun} · Shared; setup needs attention"), + ), + Some(s) if s.streamer.installed => ( + Tone::Warning, + format!("{noun} · Not shared: the streaming engine isn't running"), + ), + Some(_) => ( + Tone::Neutral, + format!("{noun} · Not shared, so others can't connect here"), + ), }; - ui::list_row(ui, name, &detail, |ui| { - if running { - ui::caption(ui, "Setting up…"); - } else if ui::primary_button( - ui, - if status - .as_ref() - .is_some_and(|s| s.streamer.running && s.streamer.api_ok) - { - "Repair sharing" - } else { - "Share this machine" - }, - ) - .clicked() - { - slot.lock().want_setup = true; + ui::list_row(ui, Some(tone), &name, &detail, |ui| { + let label = if shared { "Manage" } else { "Set up sharing" }; + if ui::secondary_button(ui, label).clicked() { + self.page = Page::Sharing; } }); - if let Some(Err(e)) = result.as_ref() { - ui::notice(ui, Tone::Danger, e); - } else if let Some(Ok(())) = result.as_ref() { - ui::notice(ui, Tone::Success, "Sharing is set up on this machine."); - } - if let Some(s) = &status { - if !s.setup.is_empty() { - for item in s.setup.iter().take(3) { - ui::dot_label(ui, Tone::Accent, item); - } - } - } - ui::row_separator(ui); } - fn pcs_card( - &mut self, - ui: &mut egui::Ui, - ctx: &egui::Context, - disc: &Discovery, - prog: &Progress, - ) { - ui::titled_card( - ui, - "Your machines", - Some("Every machine reachable on your tailnet. Connect opens its desktop when BroLink is sharing there."), - |ui| { - self.this_machine_row(ui); - if disc.pcs.is_empty() { - let text = if disc.error.is_some() || !self.tailscale_ok { - "Sign in to Tailscale to see your machines." - } else if disc.refreshed.is_none() { - "Looking for machines…" - } else { - "No other machines on this tailnet yet. Install BroLink on each one and sign in to the same Tailscale account." - }; - ui::empty_state(ui, text, disc.refreshed.is_none() && disc.error.is_none()); + /// The row's "…" menu: wake, power, copy the address. + fn row_menu(&mut self, ui: &mut egui::Ui, pc: &Pc) { + let live = !pc.remembered; + let wake = live && !pc.online && pc.can_wake(); + let test_wake = live && pc.online && pc.host.is_some() && pc.can_wake(); + let power = live && pc.online && pc.power_allowed(); + if !(wake || test_wake || power || pc.ip.is_some()) { + return; + } + ui::icon_menu(ui, Icon::More, &format!("More for {}", pc.name), |ui| { + if wake && ui::menu_item(ui, "Wake").clicked() { + self.notice = Some(match session::wake_only(pc) { + Ok(_) => ( + Tone::Neutral, + format!( + "Wake packets sent to {}. It can take a minute to come online.", + pc.name + ), + Instant::now(), + ), + Err(e) => (Tone::Danger, format!("{}: {e}", pc.name), Instant::now()), + }); + } + if test_wake && ui::menu_item(ui, "Test waking it from this network").clicked() { + self.test_wake(pc); + } + if power { + if wake || test_wake { + ui::menu_separator(ui); } - let pcs = disc.pcs.clone(); - for (i, pc) in pcs.iter().enumerate() { - if i > 0 { - ui::row_separator(ui); + if ui::menu_item(ui, "Sleep").clicked() { + if let Some(ip) = pc.ip { + self.power(ip, &pc.name, PowerAction::Sleep); } - let detail = describe(pc); - ui::list_row(ui, &pc.name, &detail, |ui| { - let busy = prog.active(); - if pc.can_stream() - && !pc.remembered - && !busy - && ui::primary_button(ui, "Connect").clicked() - { - self.connect(ctx, pc); - } - if !pc.online - && pc.can_wake() - && !busy - && ui::ghost_button(ui, "Wake").clicked() - { - self.notice = Some(match session::wake_only(pc) { - Ok(n) => ( - Tone::Info, - format!("Sent {n} wake packets to {}.", pc.name), - Instant::now(), - ), - Err(e) => (Tone::Danger, e.to_string(), Instant::now()), - }); - } - if pc.power_allowed() { - self.power_menu(ui, pc); - } - }); } - if let Some((name, action)) = self.confirm.clone() { - ui::notice( - ui, - Tone::Danger, - &format!( - "{} {name}? Programs are closed without asking; anything unsaved is lost.", - action.label() - ), - ); - ui.horizontal(|ui| { - if ui::toned_button(ui, action.label(), Tone::Danger).clicked() { - if let Some(pc) = pcs.iter().find(|p| p.name == name) { - if let Some(ip) = pc.ip { - self.power(ip, &pc.name, action); - } - } - self.confirm = None; - } - if ui::ghost_button(ui, "Cancel").clicked() { - self.confirm = None; - } - }); + if ui::menu_item(ui, "Restart…").clicked() { + self.confirm = Some((pc.name.clone(), PowerAction::Restart)); } - }, - ); + if ui::menu_item(ui, "Shut down…").clicked() { + self.confirm = Some((pc.name.clone(), PowerAction::Shutdown)); + } + } + if let Some(ip) = pc.ip { + if wake || test_wake || power { + ui::menu_separator(ui); + } + if ui::menu_item(ui, "Copy Tailscale address").clicked() { + ui.ctx().copy_text(ip.to_string()); + self.notice = Some(( + Tone::Neutral, + format!("Copied {ip}, {}'s Tailscale address.", pc.name), + Instant::now(), + )); + } + } + }); } - /// Why a PC is relayed, a PC whose host encodes in software, and a PC - /// whose host is too old to update itself: each gets one line under - /// the list, with what to do about it. - fn path_notices(&self, ui: &mut egui::Ui, disc: &Discovery) { - for text in path_warnings(disc) { - ui::notice(ui, Tone::Accent, &text); + /// What the next stream asks for, and a way to change it. + fn next_stream(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + let s = path::effective(&self.cfg.stream, &path::Path::default()); + let (w, h) = s.resolution.pixels(Self::native_pixels(ctx)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(space::SM, space::SM); + ui::section_label(ui, "Next stream"); + ui.add_space(space::XS); + ui::tag(ui, None, &format!("{w} × {h}")); + ui::tag(ui, None, &format!("{} fps", s.fps)); + ui::tag(ui, None, &format!("{} Mbps", s.bitrate_kbps / 1000)); + ui.add_space(space::XS); + if ui::link(ui, "Change").clicked() { + self.page = Page::Settings; + } + }); + } + + /// Everything worth knowing about the paths and keys that is not + /// urgent, folded away. Urgent key expiry is at the top of the page. + fn details(&self, ui: &mut egui::Ui, disc: &Discovery) { + let mut items: Vec<(Tone, String)> = path_warnings(disc) + .into_iter() + .map(|t| (Tone::Warning, t)) + .collect(); + items.extend( + key_expiry_warnings(disc) + .into_iter() + .filter(|t| !urgent(t)) + .map(|t| (Tone::Neutral, t)), + ); + if items.is_empty() { + return; } + let title = format!("Connection details ({})", items.len()); + ui::collapsible(ui, "lobby-details", &title, false, |ui| { + ui.spacing_mut().item_spacing.y = space::SM; + for (tone, text) in &items { + ui::notice(ui, *tone, text); + } + }); } /// Start installing the newest BroLink Host on the PC through the @@ -837,7 +1069,7 @@ impl ClientApp { let Some(mac_ip) = disc.self_ip else { self.view.toast( Tone::Danger, - "Tailscale on this Mac has no address to serve from.", + "Tailscale on this machine has no address to serve the update from.", ); return; }; @@ -865,7 +1097,7 @@ impl ClientApp { ctx.clone(), )); self.view.toast( - Tone::Info, + Tone::Neutral, format!("Installing BroLink Host on {name} through the stream…"), ); } @@ -895,306 +1127,369 @@ impl ClientApp { } } - /// A Tailscale node key that expires is the one thing that can take a - /// far-away PC off the tailnet with nobody there to sign it back in. - fn key_expiry_notices(&self, ui: &mut egui::Ui, disc: &Discovery) { - for text in key_expiry_warnings(disc) { - let urgent = text.contains("expired") || text.contains(" days") && days_in(&text) <= 30; - ui::notice(ui, if urgent { Tone::Danger } else { Tone::Info }, &text); + fn session_card(&mut self, ui: &mut egui::Ui, prog: &Progress) { + if prog.updating { + ui::banner( + ui, + Tone::Accent, + "Installing a BroLink update", + Some("BroLink restarts itself in a moment. New connections wait until it has."), + |_| {}, + ); + return; } - } - - fn power_menu(&mut self, ui: &mut egui::Ui, pc: &Pc) { - ui::menu_button(ui, "PC", |ui| { - if ui.button("Sleep").clicked() { - if let Some(ip) = pc.ip { - self.power(ip, &pc.name, PowerAction::Sleep); - } - ui.close_menu(); - } - if ui.button("Restart…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Restart)); - ui.close_menu(); - } - if pc.can_wake() && ui.button("Test wake").clicked() { - self.test_wake(pc); - ui.close_menu(); + let pc = &prog.pc; + let (title, explain) = match &prog.step { + Step::Waking => ( + format!("Waking {pc}"), + "BroLink sends wake packets every few seconds. A sleeping machine can take up to two minutes to come back.", + ), + Step::Waiting => ( + format!("Waiting for {pc}"), + "It is awake; its streaming engine is starting.", + ), + Step::Pairing { .. } => ( + format!("Pairing with {pc}"), + "This happens once per machine. BroLink on that machine enters this PIN by itself; nothing needs typing unless it says otherwise below.", + ), + _ => (format!("Connecting to {pc}"), ""), + }; + ui::toned_card(ui, Tone::Accent, |ui| { + ui::heading(ui, &title, None); + if let Step::Pairing { pin } = &prog.step { + ui::display_digits(ui, pin); + ui.add_space(space::XS); } - if ui.button("Shut down…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Shutdown)); - ui.close_menu(); + if !explain.is_empty() { + ui::caption(ui, explain); } - }); - } - - fn session_card(&mut self, ui: &mut egui::Ui, prog: &Progress) { - ui::toned_card(ui, Tone::Accent, |ui| { - let title = match &prog.step { - Step::Waking => "Waking the PC", - Step::Waiting => "Waiting for the PC", - Step::Pairing { .. } => "Pairing", - Step::Launching | Step::Connecting | Step::Streaming => "Connecting", - _ => "", - }; - ui::heading(ui, title, None); match &prog.step { - Step::Pairing { pin } => { - ui.label(format!( - "First time with {}. BroLink Host on the PC enters this PIN for you.", - prog.pc - )); - ui::display_digits(ui, pin); - if !prog.detail.is_empty() { - ui::notice(ui, Tone::Accent, &prog.detail); - } + Step::Pairing { .. } if !prog.detail.is_empty() => { + ui::notice(ui, Tone::Warning, &prog.detail); } - _ => ui::empty_state(ui, &prog.detail, true), + Step::Pairing { .. } => {} + _ => ui::empty_state( + ui, + if prog.detail.is_empty() { + "Starting…" + } else { + &prog.detail + }, + true, + ), + } + ui.add_space(space::XS); + if ui::secondary_button(ui, "Cancel").clicked() { + self.disconnect(); } - ui.horizontal(|ui| { - if ui::danger_button(ui, "Cancel").clicked() { - self.disconnect(); - } - }); }); } fn ended_card(&mut self, ui: &mut egui::Ui, prog: &Progress, error: Option<&str>) { - if error.is_none() && self.offer_sleep.is_none() { - return; - } - let tone = if error.is_some() { - Tone::Danger - } else { - Tone::Neutral - }; - ui::toned_card(ui, tone, |ui| { - match error { - Some(e) => { - ui::heading(ui, &format!("{} disconnected", prog.pc), None); - ui.label(e); - } - None => ui::heading( - ui, - "Session ended", - Some(&format!("Leave {} on, or put it to sleep?", prog.pc)), - ), - } - ui.horizontal(|ui| { - if let Some(pc) = self.offer_sleep.clone() { - if ui::primary_button(ui, "Sleep the PC").clicked() { - if let Some(ip) = pc.ip { - self.power(ip, &pc.name, PowerAction::Sleep); - } - self.offer_sleep = None; + let pc = prog.pc.clone(); + if let Some(e) = error { + let title = if self.streamed { + format!("{pc} disconnected") + } else { + format!("Couldn't connect to {pc}") + }; + let lighter = self.stream_failed + && self + .cfg + .stream + .preset() + .is_none_or(|p| p != crate::config::Preset::Smooth); + ui::banner(ui, Tone::Danger, &title, Some(e), |ui| { + if let Some(last) = self.last_pc.clone() { + if ui::primary_button(ui, "Try again").clicked() { + self.reconnect = Some(last.clone()); self.progress.lock().step = Step::Idle; } - } - if let (Some(_), Some(pc)) = (error, self.last_pc.clone()) { // The lighter profile is the likeliest to hold if the - // network, not the PC, ended the last one. - if ui::primary_button(ui, "Try again at Smooth (1080p · 20 Mbps)").clicked() { + // network, not the machine, ended the last one. + if lighter + && ui::secondary_button(ui, "Try at 1080p, 20 Mbps") + .on_hover_text("Switches Settings to the Smooth profile.") + .clicked() + { self.cfg.stream.apply_preset(crate::config::Preset::Smooth); self.dirty = true; - self.reconnect = Some(pc.clone()); - self.progress.lock().step = Step::Idle; - } - if ui::ghost_button(ui, "Try again").clicked() { - self.reconnect = Some(pc); + self.reconnect = Some(last); self.progress.lock().step = Step::Idle; } } - let label = if error.is_some() { - "Dismiss" - } else { - "Leave it on" - }; - if ui::ghost_button(ui, label).clicked() { + if ui::ghost_button(ui, "Dismiss").clicked() { self.offer_sleep = None; self.progress.lock().step = Step::Idle; } }); - }); + } else if let Some(target) = self.offer_sleep.clone() { + ui::banner( + ui, + Tone::Neutral, + &format!("Session with {pc} ended"), + Some("Put it to sleep, or leave it on to connect again from anywhere. Asleep, it can only be woken from its own network."), + |ui| { + if ui::primary_button(ui, &format!("Sleep {}", target.name)).clicked() { + if let Some(ip) = target.ip { + self.power(ip, &target.name, PowerAction::Sleep); + } + self.offer_sleep = None; + self.progress.lock().step = Step::Idle; + } + if ui::ghost_button(ui, "Leave it on").clicked() { + self.offer_sleep = None; + self.progress.lock().step = Step::Idle; + } + }, + ); + } } - fn settings_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, prog: &Progress) { - ui::titled_card( - ui, - "Settings", - Some("Applied the next time you connect."), - |ui| { - let native = Self::native_pixels(ctx); - if crate::settings::stream_controls(ui, &mut self.cfg.stream, native) { - self.dirty = true; - } - ui::row_separator(ui); - let s = &mut self.cfg.stream; - if ui::toggle_row( - ui, - &mut s.fullscreen, - "Full screen", - Some("Otherwise the stream fills this window."), - ) { - self.dirty = true; - } - ui::row_separator(ui); - ui::setting_row( - ui, - "App", - Some("What the PC starts. “Desktop” is the whole PC."), - |ui| { - let mut app = s.app.clone(); - let mut changed = false; - if prog.apps.is_empty() { - changed = ui - .add(egui::TextEdit::singleline(&mut app).desired_width(160.0)) - .changed(); - } else { - egui::ComboBox::from_id_salt("app_pick") - .selected_text(app.clone()) - .show_ui(ui, |ui| { - for a in &prog.apps { - if ui.selectable_value(&mut app, a.clone(), a).clicked() { - changed = true; - } - } - }); - } - if changed && !app.trim().is_empty() { - s.app = app; - self.dirty = true; - } - }, - ); - ui::row_separator(ui); - if ui::toggle_row( + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + fn settings_page( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + disc: &Discovery, + prog: &Progress, + ) { + ui::page_header( + ui, + "Settings", + Some("Stream changes apply the next time you connect."), + |_| {}, + ); + let native = Self::native_pixels(ctx); + ui::section(ui, "Stream", |ui| { + if crate::settings::stream_controls(ui, &mut self.cfg.stream, native) { + self.dirty = true; + } + }); + let (_, host_key) = stream::host_key(); + ui::section(ui, "Window and input", |ui| { + if ui::toggle_row( ui, - &mut self.cfg.cmd_is_ctrl, - "Command key acts as Ctrl", - Some("So ⌘C, ⌘V and ⌘Z do what you expect on the PC. Off makes it the Windows key."), + &mut self.cfg.stream.fullscreen, + "Open streams full screen", + Some("Otherwise the stream fills this window."), ) { self.dirty = true; } + ui::row_separator(ui); + if ui::toggle_row( + ui, + &mut self.cfg.capture_mouse, + "Capture the mouse", + Some(&format!( + "A click on the picture hides this cursor and sends raw movement, which games need. {host_key} releases it." + )), + ) { + self.dirty = true; + } + if cfg!(target_os = "macos") { ui::row_separator(ui); if ui::toggle_row( ui, - &mut self.cfg.sleep_prompt, - "Offer to sleep the PC after each session", - Some("Asleep, Tailscale is off. This Mac can only wake the PC from that PC's own network, not from elsewhere."), - ) { - self.dirty = true; - } - ui::row_separator(ui); - if ui::toggle_row( - ui, - &mut self.cfg.auto_update, - "Keep BroLink and your machines up to date", - Some("Checks GitHub every few hours, installs new versions of this app, and sends host updates to your other machines over Tailscale."), + &mut self.cfg.cmd_is_ctrl, + "Command acts as Ctrl", + Some("So Command-C, V and Z copy, paste and undo on Windows. Off, Command is the Windows key."), ) { self.dirty = true; } - if let Some(slot) = self.local.clone() { - ui::row_separator(ui); - ui::caption(ui, "THIS MACHINE"); - let g = slot.lock(); - let mut power = g.power_allowed; - let mut stay = g.stay_awake; - let mut auto = g.autostart; - drop(g); - if ui::toggle_row( - ui, - &mut power, - "Let others sleep, restart, or shut down this machine", - Some("Devices allowed by your Tailscale access rules can ask."), - ) { - slot.lock().want_power = Some(power); - } - ui::row_separator(ui); - if ui::toggle_row( - ui, - &mut stay, - "Keep this machine awake while plugged in", - Some("Tailscale only works while the machine is on."), - ) { - slot.lock().want_stay_awake = Some(stay); + } + ui::row_separator(ui); + let s = &mut self.cfg.stream; + ui::setting_row( + ui, + "App to open", + Some("What the other machine starts. Desktop is its whole screen."), + |ui| { + let mut app = s.app.clone(); + let mut changed = false; + if prog.apps.is_empty() { + changed = ui + .add( + egui::TextEdit::singleline(&mut app) + .desired_width(160.0) + .margin(egui::Margin::symmetric(8, 7)), + ) + .on_hover_text("The list fills in after the first connection.") + .changed(); + } else { + ui::select(ui, "app_pick", app.clone(), 160.0, |ui| { + for a in &prog.apps { + if ui::select_option(ui, &mut app, a.clone(), a).clicked() { + changed = true; + } + } + }); } - ui::row_separator(ui); - if ui::toggle_row( - ui, - &mut auto, - "Start BroLink when you log in", - Some( - "So others can connect after a restart without anyone at the keyboard.", - ), - ) { - slot.lock().want_autostart = Some(auto); + if changed && !app.trim().is_empty() { + s.app = app; + self.dirty = true; } + }, + ); + }); + ui::section(ui, "Power", |ui| { + if ui::toggle_row( + ui, + &mut self.cfg.sleep_prompt, + "Offer to sleep a machine after a session", + Some("Asleep, Tailscale is off there too, so it can only be woken from its own network."), + ) { + self.dirty = true; + } + }); + self.updates_section(ui); + self.relay_section(ui, disc); + self.about_section(ui); + } + + fn updates_section(&mut self, ui: &mut egui::Ui) { + ui::section(ui, "Updates", |ui| { + if ui::toggle_row( + ui, + &mut self.cfg.auto_update, + "Keep BroLink up to date", + Some("Checks GitHub every few hours, installs new versions of this app, and sends updates to your other machines over Tailscale."), + ) { + self.dirty = true; + } + ui::row_separator(ui); + let (message, checked) = { + let st = self.updates.lock(); + (st.message.clone(), st.checked) + }; + let hint = if !self.cfg.auto_update { + "Off. This app and your other machines stay on their current versions.".to_string() + } else if message.is_empty() { + format!("Waiting for the first check · {}", update::ago(checked)) + } else { + format!("{message} · {}", update::ago(checked)) + }; + ui::setting_row(ui, "Last check", Some(&hint), |ui| { + let clicked = ui + .add_enabled_ui(self.cfg.auto_update, |ui| { + ui::secondary_button(ui, "Check now") + }) + .inner + .clicked(); + if clicked { + self.updates.lock().check_now = true; } - let (message, checked) = { - let st = self.updates.lock(); - (st.message.clone(), st.checked) - }; - let hint = format!( - "{} · {}", - if message.is_empty() { - "Waiting for the first check." - } else { - &message - }, - update::ago(checked) - ); - ui::setting_row(ui, "Updates", Some(&hint), |ui| { - if ui::ghost_button(ui, "Check now").clicked() { - self.updates.lock().check_now = true; - } - }); - ui::row_separator(ui); - ui::open_source_row(ui, &mut self.notices_open); - }, - ); + }); + }); } - fn relay_card(&mut self, ui: &mut egui::Ui, disc: &Discovery) { + fn relay_section(&mut self, ui: &mut egui::Ui, disc: &Discovery) { let state = relay_state(disc); - ui::titled_card(ui, "Relay", Some(state.sentence()), |ui| match &state { - RelayState::Ready { name, ip } => { - let detail = match ip { - Some(ip) => format!("{name} · {ip}"), - None => name.clone(), - }; - ui::caption(ui, detail); - } - RelayState::Unavailable { name } => { - ui::caption( - ui, - format!( - "{name} is on the tailnet. This grant is what Tailscale needs if the relay is yours:" - ), - ); + ui::section(ui, "Relay", |ui| { + ui.add_space(space::MD); + ui.spacing_mut().item_spacing.y = space::SM; + let (tone, title) = state.title(); + ui::status_text(ui, tone, &title); + ui::caption(ui, state.sentence()); + if let RelayState::Unavailable { .. } = &state { ui::well(ui, |ui| { - ui.label(egui::RichText::new(RELAY_GRANT).monospace().color(P.muted)); + ui.label( + egui::RichText::new(RELAY_GRANT) + .font(ui::theme::mono(ui::theme::text::MONO)) + .color(P.text_secondary), + ); }); - ui.horizontal(|ui| { - if ui::ghost_button(ui, "Copy grant").clicked() { + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = space::LG; + if let RelayState::Unavailable { .. } = &state { + if ui::secondary_button(ui, "Copy grant").clicked() { ui.ctx().copy_text(RELAY_GRANT.to_string()); - self.notice = - Some((Tone::Info, "Copied the grant.".into(), Instant::now())); - } - if ui::ghost_button(ui, "How it works").clicked() { - ui.ctx().open_url(egui::OpenUrl::new_tab(RELAY_DOCS)); + self.notice = Some(( + Tone::Neutral, + "Copied the relay grant.".into(), + Instant::now(), + )); } - }); - } - RelayState::None | RelayState::Offline { .. } | RelayState::Checking { .. } => { - ui.horizontal(|ui| { - if ui::ghost_button(ui, "How it works").clicked() { - ui.ctx().open_url(egui::OpenUrl::new_tab(RELAY_DOCS)); + } + if ui::link(ui, "How peer relays work").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(RELAY_DOCS)); + } + }); + ui.add_space(space::XS); + }); + } + + fn about_section(&mut self, ui: &mut egui::Ui) { + ui::section(ui, "About", |ui| { + ui::setting_row( + ui, + &format!("BroLink {}", env!("CARGO_PKG_VERSION")), + Some("Free software under the GNU GPL, version 3 or later. It builds on Moonlight, Sunshine, Opus and the Geist typeface."), + |ui| { + if ui::link(ui, "Source code").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(SOURCE_URL)); } + }, + ); + ui::row_separator(ui); + ui.add_space(space::XS); + ui::disclosure(ui, "about-notices", "Open-source notices", false, |ui| { + ui::well(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt("open_source_notices") + .max_height(280.0) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(ui::NOTICES) + .font(ui::theme::mono(11.5)) + .color(P.text_secondary), + ) + .wrap(), + ); + }); }); - } + }); + ui.add_space(space::SM); }); } } +/// "Tailscale is stopped" → "Tailscale is stopped", with a capital and +/// without a trailing full stop, for building a sentence around it. +fn sentence(s: &str) -> String { + let s = s.trim().trim_end_matches('.'); + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } +} + +/// "This Mac", "This PC": what to call the machine BroLink runs on. +fn this_noun(os: &str) -> &'static str { + let os = if os.is_empty() { + if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else { + "linux" + } + } else { + os + }; + match os_label(os) { + "Windows" => "This PC", + "macOS" => "This Mac", + _ => "This machine", + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RelayState { None, @@ -1223,6 +1518,26 @@ impl RelayState { Self::Ready { .. } => RELAY_READY, } } + + /// The status line over the sentence. + fn title(&self) -> (Tone, String) { + match self { + Self::None => (Tone::Neutral, "No relay of your own".into()), + Self::Offline { name } => (Tone::Warning, format!("{name} is offline")), + Self::Checking { name } => (Tone::Neutral, format!("Couldn't check {name}")), + Self::Unavailable { name } => ( + Tone::Warning, + format!("{name} isn't available to this device"), + ), + Self::Ready { name, ip } => ( + Tone::Success, + match ip { + Some(ip) => format!("Using {name} ({ip})"), + None => format!("Using {name}"), + }, + ), + } + } } fn ip_listed(servers: &[String], ip: Option) -> bool { @@ -1268,36 +1583,41 @@ fn relay_state(disc: &Discovery) -> RelayState { } } -/// One line per machine whose Tailscale key expires, this Mac included. +/// One line per machine whose Tailscale key expires, this one included. fn key_expiry_warnings(disc: &Discovery) -> Vec { let mut out = Vec::new(); for pc in &disc.pcs { if let Some(d) = pc.key_expiry_days { out.push(if d <= 0 { format!( - "{}'s Tailscale key has expired: it is off the tailnet until someone signs Tailscale in at the PC.", + "{}'s Tailscale key has expired. It is off the tailnet until someone signs in to Tailscale on it.", pc.name ) } else { format!( - "{}'s Tailscale key expires in {d} days. In the Tailscale admin console (login.tailscale.com/admin/machines), open {} and choose Disable key expiry; otherwise it drops off the tailnet and needs a sign-in at the PC.", - pc.name, pc.name + "{}'s Tailscale key expires in {d} days. Turn off key expiry for it in the Tailscale admin console (login.tailscale.com/admin/machines), or it will need a sign-in on that machine.", + pc.name ) }); } } if let Some(d) = disc.self_key_days { out.push(if d <= 0 { - "This machine's Tailscale key has expired; sign in to Tailscale again.".to_string() + "This machine's Tailscale key has expired. Sign in to Tailscale again.".to_string() } else { format!( - "This machine's Tailscale key expires in {d} days; disable key expiry for it in the admin console as well." + "This machine's Tailscale key expires in {d} days. Turn off key expiry for it in the admin console too." ) }); } out } +/// A key-expiry line that needs doing now: expired, or within 30 days. +fn urgent(text: &str) -> bool { + text.contains("expired") || text.contains(" days") && days_in(text) <= 30 +} + /// The day count inside a warning line, for its tone. fn days_in(text: &str) -> i64 { text.split(" in ") @@ -1307,7 +1627,7 @@ fn days_in(text: &str) -> i64 { .unwrap_or(i64::MAX) } -/// The second line under a PC's name. Kept short: it is cut, not wrapped. +/// "Windows", "macOS", "Linux". fn os_label(os: &str) -> &'static str { let n = tailscale::Node { os: os.to_string(), @@ -1316,24 +1636,42 @@ fn os_label(os: &str) -> &'static str { n.os_label() } -fn describe(pc: &Pc) -> String { - let ip = pc.ip.map(|ip| ip.to_string()).unwrap_or_default(); +/// A machine's status dot and the line under its name. Kept short: the +/// line is cut, not wrapped, and shows whole on hover. +fn describe(pc: &Pc) -> (Tone, String) { let os = os_label(&pc.os); + let at = pc.ip.map(|ip| format!(" · {ip}")).unwrap_or_default(); + // A phone on the tailnet is listed, but it will never share a desktop: + // "BroLink isn't installed" would send someone looking for an app. + let lower = pc.os.to_ascii_lowercase(); + if lower == "ios" || lower == "android" || lower == "ipados" { + let name = if lower == "android" { "Android" } else { "iOS" }; + return ( + Tone::Neutral, + format!("{name}{at} · Phones and tablets can't share a desktop"), + ); + } if pc.remembered { let seen = pc.known.as_ref().and_then(|k| k.last_seen_unix); - return match seen { - Some(t) => format!( - "{os} · last seen {} · Tailscale is off here", - brolink_core::dates::ymd(t) - ), - None => format!("{os} · Tailscale is off here"), - }; + return ( + Tone::Neutral, + match seen { + Some(t) => format!("{os} · Last seen {}", brolink_core::dates::ymd(t)), + None => format!("{os} · Not seen yet"), + }, + ); } if !pc.online { return if pc.can_wake() { - format!("{os} · asleep or off · Connect wakes it") + ( + Tone::Neutral, + format!("{os} · Asleep or off · BroLink can wake it"), + ) } else { - format!("{os} · offline · turn it on once with BroLink running") + ( + Tone::Neutral, + format!("{os} · Offline · Turn it on to connect"), + ) }; } let path = if pc.path.direct.is_some() { @@ -1342,11 +1680,14 @@ fn describe(pc: &Pc) -> String { String::new() }; match (&pc.host, pc.sunshine) { - (Some(h), true) if h.setup.is_empty() => format!("{os} · Ready · {ip}{path}"), - (Some(_), true) => format!("{os} · Ready · {ip} · still needs setup"), - (Some(_), false) => format!("{os} · online · {ip} · not sharing yet"), - (None, true) => format!("{os} · online · {ip}{path} · no BroLink control"), - (None, false) => format!("{os} · online · {ip} · install BroLink to share"), + (Some(h), true) if h.setup.is_empty() => (Tone::Success, format!("{os}{at}{path}")), + (Some(_), true) => ( + Tone::Warning, + format!("{os}{at} · Sharing needs attention there"), + ), + (Some(_), false) => (Tone::Neutral, format!("{os}{at} · Not shared yet")), + (None, true) => (Tone::Success, format!("{os}{at}{path} · Without BroLink")), + (None, false) => (Tone::Neutral, format!("{os}{at} · BroLink isn't installed")), } } @@ -1375,13 +1716,13 @@ fn path_warnings(disc: &Discovery) -> Vec { if let Some(h) = &pc.host { if h.streamer.encoder == "software" { out.push(format!( - "{} encodes video in software: no GPU encoder there, so BroLink uses every CPU core. A still desktop should still hold the bitrate you set.", + "{} encodes video in software: it has no GPU encoder BroLink can use, so every CPU core does the work. A still desktop holds the bitrate you set; fast motion may drop frames.", pc.name )); } if !h.streamer.audio_problem.is_empty() { out.push(format!( - "{} has no sound to send: the PC reports “{}”. A PC with no monitor or speakers has no audio device to capture; give it a virtual one (Steam's Streaming Speakers, or VB-CABLE) and pick it as the PC's audio sink.", + "{} has no sound to send: it reports “{}”. A machine with no speakers or monitor has no audio device to capture; add a virtual one (Steam Streaming Speakers or VB-CABLE) and make it the default output.", pc.name, h.streamer.audio_problem )); } @@ -1414,7 +1755,7 @@ mod tests { }; assert_eq!( describe(&pc), - "Unknown · last seen 2026-09-07 · Tailscale is off here" + (Tone::Neutral, "Unknown · Last seen 2026-09-07".to_string()) ); let disc = Discovery { pcs: vec![ @@ -1453,19 +1794,38 @@ mod tests { ip: Some("203.0.113.10".parse().unwrap()), ..Default::default() }; - assert!(describe(&pc).contains("offline")); + let line = |pc: &Pc| describe(pc).1; + assert!(line(&pc).contains("Offline")); pc.known = Some(KnownPc { mac: Some("02:00:00:00:00:01".into()), ..Default::default() }); - assert!(describe(&pc).contains("asleep")); + assert!(line(&pc).contains("Asleep")); + assert!(line(&pc).contains("can wake it")); pc.online = true; - assert!(describe(&pc).contains("install BroLink to share")); + assert!(line(&pc).contains("BroLink isn't installed")); + assert_eq!(describe(&pc).0, Tone::Neutral); pc.sunshine = true; - assert!(describe(&pc).contains("no BroLink control")); - assert!(describe(&pc).len() < 80, "{}", describe(&pc)); + assert!(line(&pc).contains("Without BroLink")); + assert!(line(&pc).len() < 80, "{}", line(&pc)); pc.host = Some(brolink_core::api::Status::default()); - assert_eq!(describe(&pc), "Unknown · Ready · 203.0.113.10"); + assert_eq!( + describe(&pc), + (Tone::Success, "Unknown · 203.0.113.10".to_string()) + ); + pc.host.as_mut().unwrap().setup = vec!["The streaming engine is not installed.".into()]; + assert_eq!(describe(&pc).0, Tone::Warning, "set up there, not ready"); + pc.host.as_mut().unwrap().setup.clear(); + let phone = Pc { + os: "iOS".into(), + online: true, + ..pc.clone() + }; + assert!( + line(&phone).contains("can't share a desktop"), + "{}", + line(&phone) + ); pc.path = crate::path::Path { direct: Some(false), relay: "tok".into(), @@ -1473,8 +1833,8 @@ mod tests { ..Default::default() }; assert_eq!( - describe(&pc), - "Unknown · Ready · 203.0.113.10 · Relayed via Tokyo · 210 ms" + line(&pc), + "Unknown · 203.0.113.10 · Relayed via Tokyo · 210 ms" ); } @@ -1602,7 +1962,7 @@ mod tests { assert!( relay_state(&disc_with(online.clone(), PeerRelayServers::Unknown)) .sentence() - .contains("not a denial") + .contains("does not mean access is denied") ); assert!( relay_state(&disc_with(online.clone(), PeerRelayServers::Unknown)) @@ -1623,7 +1983,7 @@ mod tests { "Known([]) is not ACL denial" ); assert!( - empty.sentence().contains("not a denial"), + empty.sentence().contains("does not mean access is denied"), "{}", empty.sentence() ); @@ -1703,123 +2063,327 @@ mod tests { } } -/// `cargo test -p brolink-client snapshots -- --ignored` writes PNGs of each -/// screen to `target/ui-snapshots/`. +/// Screens rendered headlessly. The unignored tests check that every state +/// fits the minimum window with no control overlapping another; the +/// ignored ones write PNGs for review: +/// +/// ```text +/// cargo test -p brolink-client -p brolink-host snapshots -- --ignored +/// ``` +/// +/// Files land in `target/ui-snapshots/` as +/// `client--x@x.png`. Nothing here reads or +/// writes the real settings: the window is built with defaults and never +/// saves. #[cfg(test)] -mod snapshots { +pub(crate) mod snapshots { use super::*; use crate::config::KnownPc; use crate::session::Relay; - use brolink_core::api::Status; - - fn out_dir() -> std::path::PathBuf { + use brolink_core::api::{Status, Streamer}; + use egui_kittest::kittest::Queryable; + + pub const MIN: egui::Vec2 = egui::vec2(640.0, 420.0); + pub const TYPICAL: egui::Vec2 = egui::vec2(1280.0, 800.0); + pub const LARGE: egui::Vec2 = egui::vec2(1920.0, 1200.0); + /// Every size at both scales. + pub const MATRIX: [(egui::Vec2, f32); 6] = [ + (MIN, 1.0), + (MIN, 2.0), + (TYPICAL, 1.0), + (TYPICAL, 2.0), + (LARGE, 1.0), + (LARGE, 2.0), + ]; + /// The smallest and the usual window, for secondary states. + pub const PAIR: [(egui::Vec2, f32); 2] = [(MIN, 2.0), (TYPICAL, 2.0)]; + + pub fn out_dir() -> std::path::PathBuf { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ui-snapshots"); std::fs::create_dir_all(&dir).expect("create snapshot dir"); dir } - fn save(img: image::RgbaImage, name: &str) { + pub fn save(img: image::RgbaImage, name: &str) { let path = out_dir().join(name); img.save(&path).expect("write png"); eprintln!("wrote {}", path.display()); } - fn pcs() -> Discovery { - let ready = Pc { - node_id: "n1".into(), - name: "Gaming-PC".into(), - ip: Some("100.64.0.10".parse().unwrap()), + /// `client-lobby-1280x800@2x.png` + pub fn file_name(prefix: &str, state: &str, size: egui::Vec2, ppp: f32) -> String { + format!( + "{prefix}-{state}-{}x{}@{}x.png", + size.x as u32, size.y as u32, ppp as u32 + ) + } + + fn a_status(name: &str, os: &str) -> Status { + Status { + app: "brolink".into(), + version: env!("CARGO_PKG_VERSION").into(), + name: name.into(), + os: os.into(), + power_allowed: true, + streamer: Streamer { + kind: "BroLink".into(), + installed: true, + running: true, + api_ok: true, + encoder: "nvenc".into(), + audio_problem: String::new(), + }, + ..Default::default() + } + } + + fn ready_pc(id: &str, name: &str, os: &str, ip: &str, rtt: u32, direct: bool) -> Pc { + Pc { + node_id: id.into(), + name: name.into(), + os: os.into(), + ip: Some(ip.parse().unwrap()), online: true, - host: Some(Status { - app: "brolink".into(), - version: "3.0.0".into(), - power_allowed: true, - streamer: brolink_core::api::Streamer { - encoder: "software".into(), - ..Default::default() - }, - ..Default::default() - }), + host: Some(a_status(name, os)), path: crate::path::Path { - direct: Some(false), - relay: "tok".into(), - rtt_ms: Some(210), + direct: Some(direct), + relay: if direct { String::new() } else { "tok".into() }, + rtt_ms: Some(rtt), ..Default::default() }, sunshine: true, known: Some(KnownPc { - name: "Gaming-PC".into(), + name: name.into(), mac: Some("02:00:00:00:00:01".into()), - lan_ip: Some("192.168.1.10".into()), ..Default::default() }), - key_expiry_days: Some(176), ..Default::default() - }; - let asleep = Pc { - node_id: "n2".into(), - name: "Office".into(), + } + } + + fn asleep_pc(id: &str, name: &str) -> Pc { + Pc { + node_id: id.into(), + name: name.into(), + os: "windows".into(), ip: Some("100.64.0.30".parse().unwrap()), known: Some(KnownPc { mac: Some("aa:bb:cc:dd:ee:01".into()), ..Default::default() }), ..Default::default() - }; - let bare = Pc { + } + } + + fn found(pcs: Vec) -> Discovery { + Discovery { + login: "user@example.com".into(), + pcs, + refreshed: Some(Instant::now()), + ..Default::default() + } + } + + /// The three machines most screens show: one ready and relayed, one + /// asleep, one that streams without BroLink. + pub fn pcs() -> Discovery { + let mut gaming = ready_pc("n1", "Gaming-PC", "windows", "100.64.0.10", 210, false); + gaming.key_expiry_days = Some(176); + if let Some(h) = gaming.host.as_mut() { + h.version = "3.0.0".into(); + h.streamer.encoder = "software".into(); + } + let den = Pc { node_id: "n3".into(), name: "Den".into(), + os: "linux".into(), ip: Some("100.64.0.31".parse().unwrap()), online: true, sunshine: true, ..Default::default() }; + let mut d = found(vec![gaming, asleep_pc("n2", "Office"), den]); + d.self_nat = Some(brolink_core::api::NatReport { + udp: true, + ipv4: true, + ipv6: false, + hard: Some(false), + portmap: false, + derp: "tok".into(), + }); + d + } + + fn one() -> Discovery { + found(vec![ready_pc( + "n1", + "Studio", + "macOS", + "100.64.0.12", + 14, + true, + )]) + } + + /// A dozen machines in every state, some with long names. + fn many() -> Discovery { + let mut list = vec![ + ready_pc("a", "Gaming-PC", "windows", "100.64.0.10", 18, true), + ready_pc("b", "Studio", "macOS", "100.64.0.12", 6, true), + ready_pc( + "c", + "render-node-with-a-very-long-hostname-eu-central", + "linux", + "100.64.0.13", + 96, + true, + ), + ready_pc("d", "vps-sanjose", "linux", "100.64.0.14", 160, false), + asleep_pc("e", "Office"), + asleep_pc("f", "Living-room-PC"), + ]; + let mut setup = ready_pc("g", "Laptop", "windows", "100.64.0.16", 30, true); + setup.host.as_mut().unwrap().setup = vec!["The streaming engine is not installed.".into()]; + list.push(setup); + let mut not_shared = ready_pc("h", "Workstation", "linux", "100.64.0.17", 22, true); + not_shared.sunshine = false; + not_shared.host.as_mut().unwrap().streamer = Streamer::default(); + list.push(not_shared); + list.push(Pc { + node_id: "i".into(), + name: "phone".into(), + os: "iOS".into(), + ip: Some("100.64.0.18".parse().unwrap()), + online: true, + ..Default::default() + }); + list.push(Pc { + node_id: "j".into(), + name: "Old-Tower".into(), + os: "windows".into(), + ip: Some("100.64.0.19".parse().unwrap()), + ..Default::default() + }); + list.push(Pc { + node_id: "k".into(), + name: "Media-Server".into(), + os: "linux".into(), + ip: Some("100.64.0.20".parse().unwrap()), + online: true, + sunshine: true, + ..Default::default() + }); + let mut d = found(list); + d.login = "someone.with.a.long.address@example-mail-provider.com".into(); + d + } + + fn remembered() -> Discovery { + let pc = |id: &str, name: &str, seen| Pc { + node_id: id.into(), + name: name.into(), + os: "windows".into(), + ip: Some("100.64.0.10".parse().unwrap()), + remembered: true, + known: Some(KnownPc { + name: name.into(), + last_seen_unix: seen, + ..Default::default() + }), + ..Default::default() + }; Discovery { - error: None, - login: "user@example.com".into(), - pcs: vec![ready, asleep, bare], + error: Some("Tailscale is stopped".into()), + pcs: vec![ + pc("n1", "Gaming-PC", Some(1_788_739_200)), + pc("n2", "Office", None), + ], refreshed: Some(Instant::now()), - self_nat: Some(brolink_core::api::NatReport { - udp: true, - ipv4: true, - ipv6: false, - hard: Some(false), - portmap: false, - derp: "tok".into(), - }), ..Default::default() } } - fn build( - disc: Discovery, - prog: Progress, - settings: bool, - ) -> egui_kittest::Harness<'static, ClientApp> { - build_sized(disc, prog, settings, egui::vec2(640.0, 1100.0), 2.0) + fn relay_disc(online: bool, servers: PeerRelayServers) -> Discovery { + Discovery { + relays: vec![Relay { + name: "relay-sj".into(), + ip: Some("100.64.0.40".parse().unwrap()), + online, + }], + peer_relay_servers: servers, + ..found(vec![]) + } } - fn build_sized( - disc: Discovery, - prog: Progress, - settings: bool, - size: egui::Vec2, - ppp: f32, - ) -> egui_kittest::Harness<'static, ClientApp> { - build_with(disc, prog, settings, size, ppp, true) + /// A stand-in for the host's Sharing page. + struct Placeholder; + impl share::SharePage for Placeholder { + fn show(&mut self, ui: &mut egui::Ui) { + ui::page_header(ui, "This machine", None, |_| {}); + ui::muted(ui, "The host crate draws this page."); + } + } + + /// Everything a screen needs besides the window size. + pub struct Setup { + pub disc: Discovery, + pub prog: Progress, + pub page: Page, + pub tailscale: bool, + pub cfg: ClientConfig, + pub local: Option, + pub tweak: fn(&mut ClientApp), + } + + impl Setup { + pub fn new(disc: Discovery) -> Self { + Self { + disc, + prog: Progress::default(), + page: Page::Machines, + tailscale: true, + cfg: ClientConfig::default(), + local: None, + tweak: |_| {}, + } + } + + fn page(mut self, page: Page) -> Self { + self.page = page; + self + } + + fn step(mut self, pc: &str, step: Step, detail: &str) -> Self { + self.prog = Progress { + pc: pc.into(), + step, + detail: detail.into(), + ..Default::default() + }; + self + } + + fn tweak(mut self, f: fn(&mut ClientApp)) -> Self { + self.tweak = f; + self + } + + fn local(mut self, status: Option) -> Self { + self.local = Some(share::LocalShare { + status, + ..Default::default() + }); + self + } } - fn build_with( - disc: Discovery, - prog: Progress, - settings: bool, + pub fn build( + setup: Setup, size: egui::Vec2, ppp: f32, gpu: bool, ) -> egui_kittest::Harness<'static, ClientApp> { - let disc = Arc::new(Mutex::new(disc)); - let prog = Arc::new(Mutex::new(prog)); let mut builder = egui_kittest::Harness::builder() .with_size(size) .with_pixels_per_point(ppp) @@ -1827,9 +2391,23 @@ mod snapshots { if gpu { builder = builder.wgpu(); } + let Setup { + disc, + prog, + page, + tailscale, + cfg, + local, + tweak, + } = setup; let mut harness = builder.build_eframe(move |cc| { - let mut app = ClientApp::with_shared(cc, disc, prog, false); - app.settings_open = settings; + let mut app = ClientApp::headless(cc, disc, prog, cfg); + if let Some(local) = local { + app = app.with_local(Arc::new(Mutex::new(local)), Box::new(Placeholder)); + } + app.tailscale_ok.store(tailscale, Ordering::Relaxed); + app.open_page(page); + tweak(&mut app); app }); harness.run_steps(3); @@ -1837,9 +2415,9 @@ mod snapshots { } /// Every widget sits inside the window, and no two controls overlap. - fn assert_fits(h: &egui_kittest::Harness<'_, ClientApp>, width: f32) { + pub fn assert_fits(h: &egui_kittest::Harness<'_, S>, width: f32) { use egui::accesskit::Role; - use egui_kittest::kittest::{By, Queryable}; + use egui_kittest::kittest::By; let mut controls = Vec::new(); for node in h.query_all(By::new().predicate(|_| true)) { let Some(b) = node.raw_bounds() else { continue }; @@ -1851,7 +2429,7 @@ mod snapshots { ); if matches!( node.role(), - Role::Button | Role::CheckBox | Role::RadioButton | Role::ComboBox + Role::Button | Role::CheckBox | Role::RadioButton | Role::ComboBox | Role::Link ) { controls.push(( text, @@ -1874,70 +2452,416 @@ mod snapshots { } } - const MIN_WINDOW: egui::Vec2 = egui::vec2(640.0, 420.0); - - #[test] - fn lobby_fits_the_minimum_window() { - use egui_kittest::kittest::Queryable; - let h = build_with(pcs(), Progress::default(), false, MIN_WINDOW, 1.0, false); - assert_fits(&h, 640.0); - assert_eq!(h.query_all_by_label("Connect").count(), 3); - let mut disc = pcs(); - disc.login = "someone.with.a.long.name@example-mail-provider.com".into(); - let h = build_with(disc, Progress::default(), false, MIN_WINDOW, 1.0, false); - assert_fits(&h, 640.0); - assert!(h.query_by_label("Settings").is_some()); - } - - #[test] - fn settings_fit_the_minimum_window() { - use egui_kittest::kittest::Queryable; - let h = build_with(pcs(), Progress::default(), true, MIN_WINDOW, 1.0, false); - assert_fits(&h, 640.0); - assert!(h.query_by_label("Close settings").is_some()); - } - - #[test] - fn open_source_row_shows_the_bundled_notices() { - use egui_kittest::kittest::Queryable; - let mut h = build_with(pcs(), Progress::default(), true, MIN_WINDOW, 1.0, false); - assert!(h.query_by_label("Open source").is_some()); - assert!(h.query_by_label_contains("moonlight-common-c").is_none()); - h.get_by_label("Show notices").click(); - h.run_steps(3); - assert!(h.state().notices_open); - assert!(h.query_by_label_contains("moonlight-common-c").is_some()); - assert!(h.query_by_label("Hide notices").is_some()); - assert_fits(&h, 640.0); - } - - #[test] - fn an_empty_lobby_fits_the_minimum_window() { - let disc = Discovery { - login: "user@example.com".into(), - refreshed: Some(Instant::now()), - ..Default::default() + /// Every named state, for the fit checks and the PNGs. + fn states() -> Vec<(&'static str, Setup)> { + let failed = |pc: &str, e: &str| { + Setup::new(pcs()).step( + pc, + Step::Ended { + error: Some(e.into()), + }, + "", + ) }; - let h = build_with(disc, Progress::default(), false, MIN_WINDOW, 1.0, false); + vec![ + ("lobby", Setup::new(pcs()).local(Some(a_status("MacBook-Pro", "macOS")))), + ("lobby-one", Setup::new(one())), + ( + "lobby-many", + Setup::new(many()).local(Some({ + let mut s = a_status("MacBook-Pro", "macOS"); + s.streamer = Streamer::default(); + s + })), + ), + ("lobby-looking", Setup::new(Discovery::default())), + ("lobby-empty", Setup::new(found(vec![]))), + ( + "lobby-no-tailscale", + Setup { + tailscale: false, + ..Setup::new(Discovery::default()) + }, + ), + ("lobby-tailscale-off", Setup::new(remembered())), + ( + "lobby-service-starting", + Setup::new(one()).local(None), + ), + ( + "waking", + Setup::new(pcs()).step("Office", Step::Waking, "Waking Office… 12s"), + ), + ( + "waiting", + Setup::new(pcs()).step("Office", Step::Waiting, "Waiting for Office… 4s"), + ), + ( + "connecting", + Setup::new(pcs()).step("Gaming-PC", Step::Launching, "Measuring the path…"), + ), + ( + "pairing", + Setup::new(pcs()).step( + "Gaming-PC", + Step::Pairing { pin: "4821".into() }, + "", + ), + ), + ( + "pairing-stuck", + Setup::new(pcs()).step( + "Gaming-PC", + Step::Pairing { pin: "4821".into() }, + "BroLink on that machine isn't answering, so it can't enter the PIN. Open BroLink there and set up sharing, then try again.", + ), + ), + ( + "failed", + failed("Office", "Office didn't wake up. A wake packet reaches it only from its own network, or through a router that forwards UDP port 9 to it. Its Sharing tab in BroLink shows whether Wake-on-LAN is ready."), + ), + ( + "disconnected", + failed("Gaming-PC", "The connection to the PC was lost.").tweak(|app| { + app.streamed = true; + app.stream_failed = true; + app.last_pc = Some(pcs().pcs[0].clone()); + }), + ), + ( + "ended-offer-sleep", + Setup::new(pcs()) + .step("Gaming-PC", Step::Ended { error: None }, "") + .tweak(|app| app.offer_sleep = Some(pcs().pcs[0].clone())), + ), + ( + "confirm-restart", + Setup::new(pcs()).tweak(|app| { + app.confirm = Some(("Gaming-PC".into(), PowerAction::Restart)) + }), + ), + ( + "notice-and-update", + Setup::new(pcs()).tweak(|app| { + app.notice = Some(( + Tone::Success, + "Gaming-PC received the wake packet, so waking it from this network works.".into(), + Instant::now(), + )); + let mut u = app.updates.lock(); + u.ready = Some(Version::new(4, 1, 0)); + u.notice = Some(( + Tone::Info, + "BroLink 4.1.0 is downloaded and installs when no stream is running." + .into(), + )); + }), + ), + ( + "updating", + Setup::new(pcs()).tweak(|app| app.progress.lock().updating = true), + ), + ( + "key-expiring", + Setup::new({ + let mut d = pcs(); + d.pcs[0].key_expiry_days = Some(6); + d.self_key_days = Some(90); + d + }) + ), + ("settings", Setup::new(pcs()).page(Page::Settings)), + ( + "settings-custom", + Setup::new(pcs()).page(Page::Settings).tweak(|app| { + app.cfg.stream.apply_preset(crate::config::Preset::Sharp); + app.cfg.stream.fps = 120; + app.cfg.auto_update = false; + }), + ), + ( + "relay-unavailable", + Setup::new(relay_disc(true, PeerRelayServers::Known(vec![]))).page(Page::Settings), + ), + ("sharing", Setup::new(pcs()).local(Some(a_status("MacBook-Pro", "macOS"))).page(Page::Sharing)), + ] + } + + #[test] + fn every_state_fits_the_minimum_window() { + for (name, setup) in states() { + let h = build(setup, MIN, 1.0, false); + // A name in the assertion would be nicer; kittest panics + // inside, so say which state first. + eprintln!("checking {name}"); + assert_fits(&h, MIN.x); + } + } + + #[test] + fn every_state_fits_a_large_window() { + for (name, setup) in states() { + eprintln!("checking {name}"); + let h = build(setup, LARGE, 1.0, false); + assert_fits(&h, LARGE.x); + } + } + + #[test] + fn the_lobby_lists_machines_with_their_actions() { + let h = build(Setup::new(pcs()), MIN, 1.0, false); + assert_eq!(h.query_all_by_label("Connect").count(), 2); + assert_eq!(h.query_all_by_label("Wake and connect").count(), 1); + assert!(h.query_by_label("More for Gaming-PC").is_some()); + assert!(h.query_by_label("Settings").is_some()); + let mut disc = pcs(); + disc.login = "someone.with.a.long.name@example-mail-provider.com".into(); + let h = build(Setup::new(disc), MIN, 1.0, false); assert_fits(&h, 640.0); } #[test] - fn settings_opens_and_closes_as_a_bool_toggled_view() { - use egui_kittest::kittest::Queryable; - let mut h = build_with(pcs(), Progress::default(), false, MIN_WINDOW, 1.0, false); - assert!(!h.state().settings_open); - assert!(h.query_by_label("How it works").is_none()); + fn the_row_menu_is_a_popup_with_the_machines_actions() { + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.get_by_label("More for Gaming-PC").click(); + h.run_steps(2); + assert!(h.ctx.memory(|m| m.any_popup_open())); + for item in ["Sleep", "Restart…", "Shut down…", "Copy Tailscale address"] { + assert!(h.query_by_label(item).is_some(), "{item}"); + } + h.get_by_label("Restart…").click(); + h.run_steps(2); + assert!( + !h.ctx.memory(|m| m.any_popup_open()), + "an item closes the menu" + ); + assert_eq!( + h.state().confirm, + Some(("Gaming-PC".into(), PowerAction::Restart)) + ); + assert!( + h.query_by_label("Restart").is_some(), + "the confirming button" + ); + } + + #[test] + fn a_notices_quiet_action_lines_up_with_its_text() { + for state in ["failed", "confirm-restart"] { + let setup = states().into_iter().find(|(n, _)| *n == state).unwrap().1; + let h = build(setup, TYPICAL, 1.0, false); + let (title, action) = match state { + "failed" => ("Couldn't connect to Office", "Dismiss"), + _ => ("Restart Gaming-PC?", "Restart"), + }; + let text = h.get_by_label(title).raw_bounds().unwrap().x0; + let button = h.get_by_label(action).raw_bounds().unwrap().x0; + // A quiet button's label starts its padding in from its box; + // a filled one's edge is what lines up. + let label = if action == "Dismiss" { + button + f64::from(space::MD) + } else { + button + }; + assert!( + (label - text).abs() <= 0.5, + "{state}: {action} starts at {label}, the text at {text}" + ); + } + } + + #[test] + fn row_menus_stay_inside_the_smallest_window() { + // Gaming-PC's menu is the longest; Den's row is the lowest, so its + // menu has to open upwards. + for pc in ["Gaming-PC", "Office", "Den"] { + let mut h = build(Setup::new(pcs()), MIN, 1.0, false); + crate::stream::tests::assert_menu_fits( + &mut h, + MIN, + &format!("More for {pc}"), + "Copy Tailscale address", + ); + } + // A row near the bottom of the window, as the page first shows it: + // its menu has no room below, so it opens upwards. + let mut h = build(Setup::new(many()), MIN, 1.0, false); + h.get_by_label("More for vps-sanjose").simulate_click(); + h.run_steps(3); + let menu = ui::open_menu_rect(&h.ctx).expect("the menu is open"); + let trigger = h.get_by_label("More for vps-sanjose").raw_bounds().unwrap(); + assert!( + f64::from(menu.bottom()) <= trigger.y0, + "a low row's menu opens upwards: {menu:?}" + ); + crate::stream::tests::assert_inside( + "vps-sanjose's menu", + menu, + egui::Rect::from_min_size(egui::Pos2::ZERO, MIN), + ); + } + + #[test] + fn tab_reaches_a_rows_controls_in_reading_order() { + use egui_kittest::kittest::By; + let mut h = build(Setup::new(one()), MIN, 1.0, false); + let mut order = Vec::new(); + for _ in 0..6 { + h.press_key(egui::Key::Tab); + h.run_steps(1); + if let Some(n) = h.query_all(By::new().predicate(|n| n.is_focused())).next() { + order.push(n.label().unwrap_or_default()); + } + } + let at = |label: &str| order.iter().position(|l| l == label); + let (Some(connect), Some(more)) = (at("Connect"), at("More for Studio")) else { + panic!("Tab never reached the row: {order:?}"); + }; + assert!(connect < more, "Connect comes before its menu: {order:?}"); + assert!( + at("Machines").is_some_and(|m| m < connect), + "the tabs come first: {order:?}" + ); + } + + #[test] + fn this_machine_leads_the_list_and_opens_sharing() { + let mut h = build( + Setup::new(pcs()).local(Some(a_status("MacBook-Pro", "macOS"))), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label("MacBook-Pro").is_some()); + h.get_by_label("Manage").click(); + h.run_steps(2); + assert_eq!(h.state().page, Page::Sharing); + assert!(h + .query_by_label("The host crate draws this page.") + .is_some()); + } + + #[test] + fn the_standalone_viewer_has_no_sharing_tab() { + let h = build(Setup::new(pcs()), MIN, 1.0, false); + assert!(h.query_by_label("Sharing").is_none()); + let h = build( + Setup::new(pcs()).local(Some(a_status("MacBook-Pro", "macOS"))), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label("Sharing").is_some()); + } + + #[test] + fn tabs_switch_pages() { + let mut h = build(Setup::new(pcs()), MIN, 1.0, false); + assert_eq!(h.state().page, Page::Machines); + assert!(h.query_by_label("How peer relays work").is_none()); h.get_by_label("Settings").click(); h.run_steps(3); - assert!(h.state().settings_open); - assert!(h.query_by_label("Close settings").is_some()); - assert!(h.query_by_label("How it works").is_some()); - h.get_by_label("Close settings").click(); + assert_eq!(h.state().page, Page::Settings); + assert!(h.query_by_label("How peer relays work").is_some()); + h.get_by_label("Machines").click(); h.run_steps(3); - assert!(!h.state().settings_open); - assert!(h.query_by_label("Settings").is_some()); - assert!(h.query_by_label("How it works").is_none()); + assert_eq!(h.state().page, Page::Machines); + } + + #[test] + fn keyboard_shortcuts_switch_pages() { + let mut h = build(Setup::new(pcs()), MIN, 1.0, false); + h.press_key_modifiers(egui::Modifiers::COMMAND, egui::Key::Comma); + h.run_steps(2); + assert_eq!(h.state().page, Page::Settings); + h.press_key(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().page, Page::Machines); + h.press_key_modifiers(egui::Modifiers::COMMAND, egui::Key::Num2); + h.run_steps(2); + assert_eq!( + h.state().page, + Page::Settings, + "with no Sharing tab, the second shortcut is Settings" + ); + } + + #[test] + fn open_source_notices_unfold_in_settings() { + let mut h = build( + Setup::new(pcs()).page(Page::Settings), + egui::vec2(640.0, 3000.0), + 1.0, + false, + ); + assert!(h.query_by_label_contains("moonlight-common-c").is_none()); + h.get_by_label("Open-source notices").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("moonlight-common-c").is_some()); + assert!( + h.query_by_label_contains("Geist Mono, SIL Open Font License") + .is_some(), + "the typeface's licence is listed" + ); + assert_fits(&h, 640.0); + } + + #[test] + fn a_failure_offers_a_retry_and_a_lighter_profile_only_for_stream_failures() { + let failed = |streamed: bool| { + let mut setup = Setup::new(pcs()).step( + "Gaming-PC", + Step::Ended { + error: Some("The connection to the PC was lost.".into()), + }, + "", + ); + setup.tweak = if streamed { + |app| { + app.streamed = true; + app.stream_failed = true; + app.last_pc = Some(pcs().pcs[0].clone()); + } + } else { + |app| app.last_pc = Some(pcs().pcs[0].clone()) + }; + build(setup, MIN, 1.0, false) + }; + let h = failed(true); + assert!(h.query_by_label("Gaming-PC disconnected").is_some()); + assert!(h.query_by_label("Try again").is_some()); + assert!(h.query_by_label("Try at 1080p, 20 Mbps").is_some()); + let h = failed(false); + assert!(h.query_by_label("Couldn't connect to Gaming-PC").is_some()); + assert!(h.query_by_label("Try at 1080p, 20 Mbps").is_none()); + } + + #[test] + fn tagged_relay_nodes_stay_out_of_the_pc_list_and_in_discovery() { + let disc = discovery_from_status(TAGGED_RELAY_STATUS); + assert_eq!( + disc.pcs.iter().map(|p| p.name.as_str()).collect::>(), + ["Gaming-PC", "tagged-pc"] + ); + assert_eq!( + disc.relays + .iter() + .map(|r| r.name.as_str()) + .collect::>(), + ["relay-pc", "sj-instance"] + ); + let h = build(Setup::new(disc.clone()), MIN, 1.0, false); + assert_fits(&h, 640.0); + assert!(h.query_by_label("Gaming-PC").is_some()); + assert!(h.query_by_label("tagged-pc").is_some()); + assert!(h.query_by_label("sj-instance").is_none()); + assert!(h.query_by_label("relay-pc").is_none()); + assert_eq!(h.query_all_by_label("Connect").count(), 2); + + let h = build(Setup::new(disc).page(Page::Settings), MIN, 1.0, false); + assert_fits(&h, 640.0); + assert!(h.query_by_label("sj-instance").is_none()); + assert!(h.query_by_label("relay-pc").is_none()); } const TAGGED_RELAY_STATUS: &str = r#"{"BackendState":"Running","Peer":{ @@ -1983,66 +2907,9 @@ mod snapshots { } #[test] - fn tagged_relay_nodes_stay_out_of_the_pc_list_and_in_discovery() { - use egui_kittest::kittest::Queryable; - let disc = discovery_from_status(TAGGED_RELAY_STATUS); - assert_eq!( - disc.pcs.iter().map(|p| p.name.as_str()).collect::>(), - ["Gaming-PC", "tagged-pc"] - ); - assert_eq!( - disc.relays - .iter() - .map(|r| r.name.as_str()) - .collect::>(), - ["relay-pc", "sj-instance"] - ); - - let h = build_with( - disc.clone(), - Progress::default(), - false, - MIN_WINDOW, - 1.0, - false, - ); - assert_fits(&h, 640.0); - assert!(h.query_by_label("Gaming-PC").is_some()); - assert!(h.query_by_label("tagged-pc").is_some()); - assert!(h.query_by_label("sj-instance").is_none()); - assert!(h.query_by_label("relay-pc").is_none()); - assert_eq!(h.query_all_by_label("Connect").count(), 2); - - let h = build_with(disc, Progress::default(), true, MIN_WINDOW, 1.0, false); - assert_fits(&h, 640.0); - assert!(h.query_by_label("How it works").is_some()); - assert!(h.query_by_label("sj-instance").is_none()); - assert!(h.query_by_label("relay-pc").is_none()); - } - - fn relay_disc(online: bool, servers: PeerRelayServers) -> Discovery { - Discovery { - login: "user@example.com".into(), - refreshed: Some(Instant::now()), - relays: vec![Relay { - name: "relay-sj".into(), - ip: Some("100.64.0.40".parse().unwrap()), - online, - }], - peer_relay_servers: servers, - ..Default::default() - } - } - - #[test] - fn relay_card_fits_the_minimum_window_in_every_state() { - use egui_kittest::kittest::Queryable; + fn relay_section_fits_the_minimum_window_in_every_state() { let states = [ - Discovery { - login: "user@example.com".into(), - refreshed: Some(Instant::now()), - ..Default::default() - }, + found(vec![]), relay_disc(false, PeerRelayServers::Unknown), relay_disc(true, PeerRelayServers::Unknown), relay_disc(true, PeerRelayServers::Known(vec![])), @@ -2050,227 +2917,197 @@ mod snapshots { relay_disc(true, PeerRelayServers::Known(vec!["100.64.0.40".into()])), ]; for disc in states { - let h = build_with(disc, Progress::default(), true, MIN_WINDOW, 1.0, false); + let h = build(Setup::new(disc).page(Page::Settings), MIN, 1.0, false); assert_fits(&h, 640.0); } - let h = build_with( - relay_disc(true, PeerRelayServers::Known(vec![])), - Progress::default(), - true, - MIN_WINDOW, + let tall = egui::vec2(640.0, 3000.0); + let h = build( + Setup::new(relay_disc(true, PeerRelayServers::Known(vec![]))).page(Page::Settings), + tall, 1.0, false, ); assert!(h.query_by_label("Copy grant").is_some()); - assert!(h.query_by_label("How it works").is_some()); - let h = build_with( - Discovery { - login: "user@example.com".into(), - refreshed: Some(Instant::now()), - ..Default::default() - }, - Progress::default(), - true, - MIN_WINDOW, + assert!(h.query_by_label("How peer relays work").is_some()); + let h = build( + Setup::new(found(vec![])).page(Page::Settings), + tall, 1.0, false, ); assert!(h.query_by_label("Copy grant").is_none()); - assert!(h.query_by_label("How it works").is_some()); - let h = build_with( - relay_disc(true, PeerRelayServers::Known(vec!["100.64.0.40".into()])), - Progress::default(), - true, - MIN_WINDOW, + assert!(h.query_by_label("How peer relays work").is_some()); + let h = build( + Setup::new(relay_disc( + true, + PeerRelayServers::Known(vec!["100.64.0.40".into()]), + )) + .page(Page::Settings), + tall, 1.0, false, ); assert!(h.query_by_label("Copy grant").is_none()); + assert!(h.query_by_label("Using relay-sj (100.64.0.40)").is_some()); } - /// The stream screen with its toolbar, before any picture has arrived: - /// the session points at an address that never answers. - #[test] - #[ignore = "renders with a GPU; run on demand to review the UI"] - fn stream_toolbar() { - let mut h = build_sized( - pcs(), - Progress::default(), - false, - egui::vec2(1400.0, 860.0), - 1.0, - ); - let ctx = h.ctx.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - let frames = Arc::new(brolink_stream::FrameSlot::default()); - let path = crate::path::Path { - direct: Some(false), - relay: "tok".into(), - rtt_ms: Some(210), - ..Default::default() - }; - let settings = crate::path::effective(&crate::config::StreamSettings::default(), &path); - let session = brolink_stream::Session::start( - brolink_stream::session::Server { - address: "10.255.255.1".into(), - app_version: "7.1.431.-1".into(), - gfe_version: "3.23.0.74".into(), - rtsp_url: "rtsp://10.255.255.1:48010".into(), - codec_mode_support: 1, - }, - brolink_stream::Settings { - width: 1920, - height: 1080, - fps: 30, - bitrate_kbps: 4000, - hevc: true, - remote: true, - }, - [0; 16], - [0; 16], - frames.clone(), - tx, - || {}, - ); - let ip: std::net::Ipv4Addr = "100.64.0.10".parse().unwrap(); - let input = session.input(); - let live = Live { - pc: "Gaming-PC".into(), - node_id: "n1".into(), - ip, - session, - input, - frames, - events: rx, - started: Instant::now(), - codec: "HEVC", - requested: (1920, 1080, 30), - settings, - path, - clipboard: crate::clipboard::Sync::spawn(ip, ctx), - }; - *h.state().live.lock() = Some(live); - h.state().progress.lock().step = Step::Streaming; - h.run_steps(3); - save(h.render().unwrap(), "client-stream-toolbar.png"); - h.state_mut().disconnect(); - } - + /// The window never repaints on a timer while nothing is happening. #[test] - #[ignore = "renders with a GPU; run on demand to review the UI"] - fn lobby() { - let mut h = build(pcs(), Progress::default(), false); - save(h.render().unwrap(), "client-lobby.png"); - let mut h = build(pcs(), Progress::default(), true); - save(h.render().unwrap(), "client-settings.png"); - let mut h = build_sized(pcs(), Progress::default(), false, MIN_WINDOW, 2.0); - save(h.render().unwrap(), "client-lobby-640x420.png"); + fn an_idle_window_asks_for_no_repaint() { + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.run_steps(4); + let delay = h + .output() + .viewport_output + .get(&egui::ViewportId::ROOT) + .map(|v| v.repaint_delay) + .unwrap_or(Duration::MAX); + assert_eq!(delay, Duration::MAX, "the idle lobby keeps repainting"); } #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn pairing_and_waking() { - let mut p = Progress { - pc: "Gaming-PC".into(), - step: Step::Pairing { pin: "4821".into() }, - ..Default::default() - }; - let mut h = build(pcs(), p.clone(), false); - save(h.render().unwrap(), "client-pairing.png"); - p.step = Step::Waking; - p.detail = "Waking Gaming-PC… 12s".into(); - let mut h = build(pcs(), p, false); - save(h.render().unwrap(), "client-waking.png"); + fn snapshots_every_state() { + let key = [ + "lobby", + "lobby-many", + "lobby-no-tailscale", + "pairing", + "failed", + "settings", + ]; + for (name, _) in states() { + let sizes: &[(egui::Vec2, f32)] = if key.contains(&name) { &MATRIX } else { &PAIR }; + for &(size, ppp) in sizes { + let setup = states().into_iter().find(|(n, _)| *n == name).unwrap().1; + let mut h = build(setup, size, ppp, true); + save(h.render().unwrap(), &file_name("client", name, size, ppp)); + } + } } + /// Long pages whole, to review what scrolls. #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn failed_and_empty() { - let p = Progress { - pc: "Office".into(), - step: Step::Ended { - error: Some("Office did not wake up. A wake packet only reaches it from its own network, or through a router that forwards UDP 9 to it.".into()), - }, - ..Default::default() - }; - let mut h = build(pcs(), p, false); - save(h.render().unwrap(), "client-failed.png"); + fn snapshots_full_pages() { + for (name, setup, h) in [ + ("settings", Setup::new(pcs()).page(Page::Settings), 2300.0), + ( + "relay-unavailable", + Setup::new(relay_disc(true, PeerRelayServers::Known(vec![]))).page(Page::Settings), + 2400.0, + ), + ("lobby-many", Setup::new(many()), 1100.0), + ( + "key-expiring", + Setup::new({ + let mut d = pcs(); + d.pcs[0].key_expiry_days = Some(6); + d.self_key_days = Some(90); + d + }), + 1000.0, + ), + ] { + let size = egui::vec2(1280.0, h); + let mut harness = build(setup, size, 1.0, true); + save( + harness.render().unwrap(), + &format!("client-{name}-full.png"), + ); + } let mut h = build( - Discovery { - error: Some("Tailscale is stopped".into()), - refreshed: Some(Instant::now()), - ..Default::default() - }, - Progress::default(), - false, + Setup::new(pcs()).page(Page::Settings), + egui::vec2(1280.0, 3000.0), + 1.0, + true, ); - save(h.render().unwrap(), "client-no-tailscale.png"); + h.get_by_label("Open-source notices").click(); + h.run_steps(3); + save(h.render().unwrap(), "client-open-source-full.png"); + let mut h = build(Setup::new(pcs()), TYPICAL, 2.0, true); + h.get_by_label("More for Gaming-PC").click(); + h.run_steps(3); + save(h.render().unwrap(), "client-row-menu-1280x800@2x.png"); + // At the smallest size: Tab reaches the longest menu's last item, + // and a low row's menu, opened where the page first shows it, + // opens upwards. + for ppp in [1.0, 2.0] { + let mut h = build(Setup::new(pcs()), MIN, ppp, true); + crate::stream::tests::assert_menu_fits( + &mut h, + MIN, + "More for Gaming-PC", + "Copy Tailscale address", + ); + save( + h.render().unwrap(), + &file_name("client", "row-menu", MIN, ppp), + ); + let mut h = build(Setup::new(many()), MIN, ppp, true); + h.get_by_label("More for vps-sanjose").simulate_click(); + h.run_steps(3); + let menu = ui::open_menu_rect(&h.ctx).expect("the menu is open"); + let trigger = h.get_by_label("More for vps-sanjose").raw_bounds().unwrap(); + assert!( + f64::from(menu.bottom()) <= trigger.y0, + "a low row's menu opens upwards: {menu:?}" + ); + crate::stream::tests::assert_inside( + "vps-sanjose's menu", + menu, + egui::Rect::from_min_size(egui::Pos2::ZERO, MIN), + ); + save( + h.render().unwrap(), + &file_name("client", "row-menu-low-row", MIN, ppp), + ); + } + let mut h = build(Setup::new(pcs()), TYPICAL, 2.0, true); + h.get_by_label_contains("Connection details").click(); + h.run_steps(3); + save(h.render().unwrap(), "client-details-open-1280x800@2x.png"); } + /// Keyboard focus is visible on every kind of control. #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn open_source_notices() { - use egui_kittest::kittest::Queryable; - let mut h = build_sized( - pcs(), - Progress::default(), - true, - egui::vec2(1024.0, 2400.0), - 1.0, - ); - h.get_by_label("Show notices").click(); - h.run_steps(3); - save(h.render().unwrap(), "client-open-source.png"); + fn snapshots_keyboard_focus() { + for (name, page, tabs) in [ + ("lobby", Page::Machines, 5), + ("settings", Page::Settings, 5), + ("settings-switch", Page::Settings, 13), + ] { + let mut h = build(Setup::new(pcs()).page(page), TYPICAL, 2.0, true); + for _ in 0..tabs { + h.press_key(egui::Key::Tab); + h.run_steps(1); + } + h.run_steps(2); + assert!( + h.ctx.memory(|m| m.focused()).is_some(), + "Tab reaches a control on {name}" + ); + save( + h.render().unwrap(), + &format!("client-focus-{name}-1280x800@2x.png"), + ); + } } + /// The stream screen as the window shows it, before any picture has + /// arrived: the session points at an address that never answers. #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn relay_card_states() { - let none = Discovery { - login: "user@example.com".into(), - refreshed: Some(Instant::now()), - ..Default::default() - }; - let mut h = build(none, Progress::default(), true); - save(h.render().unwrap(), "client-relay-none.png"); - let mut h = build( - relay_disc(false, PeerRelayServers::Unknown), - Progress::default(), - true, - ); - save(h.render().unwrap(), "client-relay-offline.png"); - let mut h = build( - relay_disc(true, PeerRelayServers::Unknown), - Progress::default(), - true, - ); - save(h.render().unwrap(), "client-relay-checking.png"); - let mut h = build( - relay_disc(true, PeerRelayServers::Known(vec![])), - Progress::default(), - true, - ); - save(h.render().unwrap(), "client-relay-unavailable.png"); - let mut h = build( - relay_disc(true, PeerRelayServers::Known(vec!["100.64.0.99".into()])), - Progress::default(), - true, - ); - save(h.render().unwrap(), "client-relay-mismatch.png"); - let mut h = build( - relay_disc(true, PeerRelayServers::Known(vec!["100.64.0.40".into()])), - Progress::default(), - true, - ); - save(h.render().unwrap(), "client-relay-ready.png"); - let mut h = build_sized( - relay_disc(true, PeerRelayServers::Known(vec![])), - Progress::default(), - true, - MIN_WINDOW, - 2.0, - ); - save(h.render().unwrap(), "client-relay-unavailable-640x420.png"); + fn snapshots_stream_connecting() { + let mut h = build(Setup::new(pcs()), egui::vec2(1400.0, 860.0), 1.0, true); + let ctx = h.ctx.clone(); + let live = crate::stream::tests::dummy_live(&ctx); + *h.state().live.lock() = Some(live); + h.state().progress.lock().step = Step::Streaming; + h.run_steps(3); + save(h.render().unwrap(), "client-stream-connecting.png"); + h.state_mut().disconnect(); } } @@ -2316,7 +3153,7 @@ mod live_snapshot { .build_eframe({ let (disc, prog) = (disc.clone(), prog.clone()); move |cc| { - let mut app = ClientApp::with_shared(cc, disc, prog, false); + let mut app = ClientApp::with_shared(cc, disc, prog, ClientConfig::load()); app.cfg.stream.fullscreen = false; if std::env::var_os("BROLINK_TEST_PC").is_none() { app.cfg.stream.resolution = Resolution::P1080; diff --git a/crates/client/src/path.rs b/crates/client/src/path.rs index eba5cfe..26f943c 100644 --- a/crates/client/src/path.rs +++ b/crates/client/src/path.rs @@ -71,14 +71,16 @@ impl Path { } } - /// A DERP detour is the one thing worth red; a relay of your own is a - /// detour too, but one sized to the round trip, so it gets the colour a - /// long direct path gets. + /// A DERP detour is the one thing worth a warning: it works, but it is + /// shared and often fixable (see [`explain`]). A relay of your own is a + /// detour chosen on purpose, and a long direct path is just distance, + /// so both are plain; only a short direct path is green. Nothing about + /// a path is red: red means broken. pub fn tone(&self) -> Tone { match (path_kind(self), self.rtt_ms) { - (Some(PathKind::Derp), _) => Tone::Danger, - (Some(PathKind::PeerRelay), _) => Tone::Accent, - (Some(PathKind::Direct), Some(ms)) if ms >= 80 => Tone::Accent, + (Some(PathKind::Derp), _) => Tone::Warning, + (Some(PathKind::PeerRelay), _) => Tone::Neutral, + (Some(PathKind::Direct), Some(ms)) if ms >= 80 => Tone::Neutral, (Some(PathKind::Direct), _) => Tone::Success, (None, _) => Tone::Neutral, } @@ -135,13 +137,13 @@ pub fn explain( } if p.ipv6 && !m.ipv6 { t.push_str(&format!( - " {pc} has IPv6; a network with IPv6 on this Mac's side would also connect directly." + " {pc} has IPv6; a network with IPv6 on this machine's side would also connect directly." )); } t } else if mac_hard { format!( - "{pc}'s network is fine; this Mac's is a hard NAT with no UPnP. From another network, or with UPnP on this router, the connection is direct." + "{pc}'s network is fine; this machine's is a hard NAT with no UPnP. From another network, or with UPnP on this router, the connection is direct." ) } else { "Both networks look easy to traverse, so Tailscale should switch to a direct path within a minute of traffic. If it never does, the routers may be blocking UDP between them.".to_string() @@ -162,7 +164,7 @@ pub fn explain( "{pc}'s router is a hard NAT with no UPnP. Turn UPnP or NAT-PMP on in that router, or forward a UDP port to {pc}; Tailscale then connects directly." ) } else { - format!("{pc}'s network looks easy to traverse; this Mac's side has not been checked yet.") + format!("{pc}'s network looks easy to traverse; this machine's side has not been checked yet.") } } (None, None) => { @@ -197,7 +199,11 @@ mod tests { ..Default::default() }; assert_eq!(p.label(), "Relayed via Tokyo · 210 ms"); - assert_eq!(p.tone(), Tone::Danger); + assert_eq!( + p.tone(), + Tone::Warning, + "a detour worth fixing, not a failure" + ); let p = Path { direct: Some(true), relay: "tok".into(), @@ -213,8 +219,8 @@ mod tests { }; assert_eq!( p.tone(), - Tone::Accent, - "a long round trip is worth a colour" + Tone::Neutral, + "a long round trip is distance, not a fault" ); assert_eq!(Path::default().label(), "Path unknown"); assert_eq!(Path::default().tone(), Tone::Neutral); @@ -237,7 +243,7 @@ mod tests { rtt_ms: Some(60), }; assert_eq!(p.label(), "Via your relay · 60 ms"); - assert_eq!(p.tone(), Tone::Accent, "a detour, not an alarm"); + assert_eq!(p.tone(), Tone::Neutral, "a detour chosen on purpose"); assert!(!p.label().contains("Tokyo")); // Given: the relay is named before any traffic has flowed. @@ -246,7 +252,7 @@ mod tests { ..Default::default() }; assert_eq!(early.label(), "Via your relay"); - assert_eq!(early.tone(), Tone::Accent); + assert_eq!(early.tone(), Tone::Neutral); // Given: a direct path holds while Tailscale still names the relay. let direct = Path { @@ -392,7 +398,7 @@ mod tests { let pc_easy = nat(true, Some(false), true, false); let t = explain("Gaming-PC", &relayed, Some(&pc_easy), Some(&mac_hard)).unwrap(); - assert!(t.contains("this Mac's is a hard NAT"), "{t}"); + assert!(t.contains("this machine's is a hard NAT"), "{t}"); let t = explain("Gaming-PC", &relayed, Some(&pc_easy), Some(&mac_easy)).unwrap(); assert!(t.contains("within a minute"), "{t}"); diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 56a3d0f..2abdd08 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -573,7 +573,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // 1. Wake it if nothing answers. if !t.online && !port_open(t.ip, SUNSHINE_PORT, Duration::from_millis(1200)) { let mac = t.mac.ok_or_else(|| { - anyhow!("{} is not answering and this Mac does not know how to wake it yet. Turn the PC on once while BroLink Host is running so it can learn.", t.name) + anyhow!("{} isn't answering, and BroLink doesn't know how to wake it yet. Turn it on once while BroLink runs there, so this machine can learn how.", t.name) })?; let start = Instant::now(); let mut last_wake = Instant::now() - Duration::from_secs(60); @@ -597,7 +597,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { } if start.elapsed() > Duration::from_secs(120) { bail!( - "{} did not wake up. A wake packet only reaches it from its own network, or through a router that forwards UDP 9 to it. Check Wake-on-LAN in BroLink Host on the PC.", + "{} didn't wake up. A wake packet reaches it only from its own network, or through a router that forwards UDP port 9 to it. Its Sharing tab in BroLink shows whether Wake-on-LAN is ready.", t.name ); } @@ -617,7 +617,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { ); if start.elapsed() > Duration::from_secs(60) { bail!( - "{} is up but nothing is streaming from it. Open BroLink Host on the PC and run setup.", + "{} is on, but nothing is streaming from it. Open BroLink there and set up sharing on its Sharing tab.", t.name ); } @@ -698,7 +698,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { } if stopped.elapsed() >= Duration::from_secs(10) { bail!( - "{} did not stop the previous stream. Check BroLink Host there.", + "{} didn't stop its previous stream. Try again; if it keeps happening, restart that machine.", t.name ); } @@ -813,7 +813,7 @@ fn submit_pin(ip: Ipv4Addr, pin: &str, progress: &Mutex) { Err(e) => { tracing::info!("BroLink Host did not take the PIN: {e}"); progress.lock().detail = - "BroLink Host is not answering on the PC. Open BroLink Host there and run setup, then try again.".into(); + "BroLink on that machine isn't answering, so it can't enter the PIN. Open BroLink there and set up sharing, then try again.".into(); } } std::thread::sleep(Duration::from_millis(800)); @@ -852,16 +852,19 @@ pub fn wake_test(pc: &Pc) -> Result { wake_only(pc)?; std::thread::sleep(Duration::from_millis(1500)); let st = host_status(ip, Duration::from_secs(3)) - .ok_or_else(|| anyhow!("BroLink Host on {} is not answering", pc.name))?; + .ok_or_else(|| anyhow!("BroLink on {} isn't answering", pc.name))?; Ok(st.wake_packet_age_secs.is_some_and(|s| s <= 5)) } /// Send the wake packet once, without connecting. pub fn wake_only(pc: &Pc) -> Result { let t = Target::from_pc(pc).ok_or_else(|| anyhow!("no address for {}", pc.name))?; - let mac = t - .mac - .ok_or_else(|| anyhow!("this Mac does not know {}'s MAC address yet", pc.name))?; + let mac = t.mac.ok_or_else(|| { + anyhow!( + "BroLink doesn't know how to wake {} yet; turn it on once while BroLink runs there", + pc.name + ) + })?; Ok(wake::send(mac, t.lan_ip, t.public_ip)) } diff --git a/crates/client/src/settings.rs b/crates/client/src/settings.rs index 5523e2c..6376c4c 100644 --- a/crates/client/src/settings.rs +++ b/crates/client/src/settings.rs @@ -1,81 +1,161 @@ -//! Shared controls for the lobby and the live session. +//! The stream controls, shared by Settings and the in-stream panel so the +//! two cannot drift apart. Rows only: the caller supplies the group. + use crate::config::{Codec, Preset, Quality, Resolution, StreamSettings}; -use brolink_ui::{self as ui, Tone}; +use brolink_ui::{self as ui, space}; + +/// The one-line choice at the top: a named profile, or the values as set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Profile { + Recommended, + Preset(Preset), + Custom, +} + +impl Profile { + fn of(s: &StreamSettings) -> Self { + match (s.quality, s.preset()) { + (Quality::Auto, _) => Profile::Recommended, + (Quality::Custom, Some(p)) => Profile::Preset(p), + (Quality::Custom, None) => Profile::Custom, + } + } +} +/// Edit `s` in place; true when anything changed. pub fn stream_controls(ui: &mut egui::Ui, s: &mut StreamSettings, native: (u32, u32)) -> bool { let before = s.clone(); - ui::caption(ui, "QUICK PROFILES"); - ui.horizontal_wrapped(|ui| { - for preset in Preset::ALL { - if ui::ghost_button(ui, preset.label()) - .on_hover_text(preset.describe()) - .clicked() - { - s.apply_preset(preset); + let mut profile = Profile::of(s); + let options = [ + (Profile::Recommended, "Recommended"), + (Profile::Preset(Preset::Smooth), Preset::Smooth.label()), + (Profile::Preset(Preset::Balanced), Preset::Balanced.label()), + (Profile::Preset(Preset::Sharp), Preset::Sharp.label()), + (Profile::Custom, "Custom"), + ]; + let hint = match profile { + Profile::Recommended => "This display's own size at 50 Mbps. A relay or a long round trip never lowers the frame rate or the bitrate.".to_string(), + Profile::Preset(p) => format!("{}.", p.describe()), + Profile::Custom => "Your own resolution, frame rate and bitrate.".to_string(), + }; + ui::setting_block(ui, "Quality", Some(&hint), |ui| { + if ui::segmented(ui, &options, &mut profile) { + match profile { + Profile::Recommended => { + s.quality = Quality::Auto; + s.resolution = Resolution::Native; + } + Profile::Preset(p) => s.apply_preset(p), + Profile::Custom => { + // Start from what Recommended was asking for, so + // nothing jumps. + if s.quality == Quality::Auto { + s.resolution = Resolution::Native; + s.bitrate_kbps = 50_000; + } + s.quality = Quality::Custom; + } } } - if ui::ghost_button(ui, "Reset").clicked() { - *s = StreamSettings { - app: s.app.clone(), - fullscreen: s.fullscreen, - ..Default::default() - }; - } - }); - ui::row_separator(ui); - ui::setting_row(ui, "Picture quality", Some("Recommended starts at 50 Mbps. A relay or a long round trip never lowers your frame rate or caps your bitrate."), |ui| { - if ui::segmented(ui, &[(Quality::Auto, "Recommended"), (Quality::Custom, "Manual")], &mut s.quality) && s.quality == Quality::Auto { s.resolution = Resolution::Native; } }); ui::row_separator(ui); - ui::setting_row(ui, "Resolution", Some("Match screen is this display’s own pixel size, so the picture fills it exactly. 1080p, 1440p and 4K are the standard 16:9 sizes; on a display of another shape they leave a bar above and below."), |ui| { - let mut resolution = s.resolution; - egui::ComboBox::from_id_salt("stream-resolution").selected_text(resolution.describe(native)).show_ui(ui, |ui| { - for r in [Resolution::Native, Resolution::P1080, Resolution::P1440, Resolution::P2160] { ui.selectable_value(&mut resolution, r, r.describe(native)); } - }); - if resolution != s.resolution { s.resolution = resolution; s.quality = Quality::Custom; } - }); + ui::setting_row( + ui, + "Resolution", + Some("Match screen fills this display exactly. The 16:9 sizes leave bars on a display of another shape."), + |ui| { + let mut resolution = s.resolution; + ui::select(ui, "stream-resolution", resolution.describe(native), 200.0, |ui| { + for r in [ + Resolution::Native, + Resolution::P1080, + Resolution::P1440, + Resolution::P2160, + ] { + ui::select_option(ui, &mut resolution, r, r.describe(native)); + } + }); + if resolution != s.resolution { + s.resolution = resolution; + s.quality = Quality::Custom; + } + }, + ); ui::row_separator(ui); - ui::setting_row(ui, "Frame rate", Some("60 fps is a good starting point. Higher rates need a matching host display and more bandwidth."), |ui| { - egui::ComboBox::from_id_salt("stream-fps").selected_text(format!("{} fps", s.fps)).show_ui(ui, |ui| { - for fps in [30, 60, 90, 120, 144, 165, 240] { ui.selectable_value(&mut s.fps, fps, format!("{fps} fps")); } - }); - }); + ui::setting_row( + ui, + "Frame rate", + Some("Above 60 needs a display there that runs that fast."), + |ui| { + ui::select(ui, "stream-fps", format!("{} fps", s.fps), 120.0, |ui| { + for fps in [30, 60, 90, 120, 144, 165, 240] { + ui::select_option(ui, &mut s.fps, fps, format!("{fps} fps")); + } + }); + }, + ); ui::row_separator(ui); ui::setting_row( ui, - "Bitrate target", - Some("The PC's encoder aims at exactly this rate (CBR) while the picture is changing; a still screen uses less."), + "Bitrate", + Some("The encoder aims at this while the picture moves; a still screen uses less."), |ui| { let mut mbps = if s.quality == Quality::Auto { 50 } else { s.bitrate_kbps / 1000 }; - ui.spacing_mut().slider_width = ui.available_width().min(200.0); - if ui - .add(egui::Slider::new(&mut mbps, 2..=150).suffix(" Mbps")) - .changed() - { + if ui::slider(ui, &mut mbps, 2..=150, 180.0, "Bitrate").changed() { s.quality = Quality::Custom; s.bitrate_kbps = mbps * 1000; } + ui::slider_value(ui, &format!("{mbps} Mbps")); }, ); ui::row_separator(ui); - ui::setting_row(ui, "Video format", Some("HEVC gives sharper detail per megabit. Auto uses HEVC when supported; H.264 improves compatibility."), |ui| { - ui::segmented(ui, &[(Codec::Auto, "Auto"), (Codec::Hevc, "HEVC"), (Codec::H264, "H.264")], &mut s.codec); - }); - let effective = crate::path::effective(s, &crate::path::Path::default()); - let (w, h) = effective.resolution.pixels(native); - ui.add_space(12.0); - ui::status_pill( + ui::setting_row( ui, - &format!( - "{w} × {h} · {} fps · {} Mbps target", - effective.fps, - effective.bitrate_kbps / 1000 - ), - Tone::Info, + "Codec", + Some("HEVC is sharper for the same bitrate. Auto uses it when both ends can."), + |ui| { + ui::segmented( + ui, + &[ + (Codec::Auto, "Auto"), + (Codec::Hevc, "HEVC"), + (Codec::H264, "H.264"), + ], + &mut s.codec, + ); + }, ); + ui::row_separator(ui); + let effective = crate::path::effective(s, &crate::path::Path::default()); + let (w, h) = effective.resolution.pixels(native); + ui.add_space(space::MD); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = space::SM; + ui::section_label(ui, "Next stream"); + ui.add_space(space::XS); + ui::tag(ui, None, &format!("{w} × {h}")); + ui::tag(ui, None, &format!("{} fps", effective.fps)); + ui::tag(ui, None, &format!("{} Mbps", effective.bitrate_kbps / 1000)); + }); + ui.add_space(space::MD); *s != before } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_profile_follows_the_values() { + let mut s = StreamSettings::default(); + assert_eq!(Profile::of(&s), Profile::Recommended); + s.apply_preset(Preset::Smooth); + assert_eq!(Profile::of(&s), Profile::Preset(Preset::Smooth)); + s.fps = 144; + assert_eq!(Profile::of(&s), Profile::Custom); + } +} diff --git a/crates/client/src/share.rs b/crates/client/src/share.rs index 06e02c7..0bb4a45 100644 --- a/crates/client/src/share.rs +++ b/crates/client/src/share.rs @@ -1,6 +1,7 @@ -//! This machine's sharing side, filled by the unified app from the local -//! control service. The lobby shows it so every BroLink install is both a -//! viewer and a host. +//! This machine's sharing side, as the unified window sees it. The host +//! crate fills [`LocalShare`] from the local control service and draws the +//! Sharing page through [`SharePage`]; the viewer only shows a summary row +//! in the machine list and hosts the page in its window. use brolink_core::api::Status; use parking_lot::Mutex; @@ -11,15 +12,12 @@ pub struct LocalShare { pub status: Option, pub setup_running: bool, pub setup_result: Option>, - /// The window asked to run setup; the unified app clears this after it - /// starts the platform setup. - pub want_setup: bool, - pub power_allowed: bool, - pub stay_awake: bool, - pub autostart: bool, - pub want_power: Option, - pub want_stay_awake: Option, - pub want_autostart: Option, } pub type Slot = Arc>; + +/// The Sharing page: setup, what this machine reports, paired devices, +/// options and diagnostics. Drawn by whoever shares this machine. +pub trait SharePage { + fn show(&mut self, ui: &mut egui::Ui); +} diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 49de0a4..f853be4 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -1,11 +1,14 @@ -//! The stream screen: the PC's picture filling the window, and nothing -//! else until Ctrl+Alt is pressed. That frees the mouse and drops a toolbar -//! over the top of the picture, the way a hypervisor's host key does; a -//! click on the picture (or Ctrl+Alt again) hides it and captures the -//! mouse back. Short notices (clipboard, connection, an install in -//! progress) appear under the toolbar's place and fade. +//! The stream screen: the other machine's picture filling the window, and +//! nothing else until the host key (Ctrl+Alt; Control-Option on a Mac) is +//! pressed. That frees the mouse and drops a toolbar over the top of the +//! picture, the way a hypervisor's host key does; a click on the picture +//! (or the host key again) hides it and captures the mouse back. Short +//! notices (clipboard, connection, an install in progress) appear under +//! the toolbar's place and go after a few seconds. //! -//! allow: SIZE_OK — one stream view; later UI tasks own any split. +//! Nothing here costs a frame while the toolbar is away: the bar, its +//! menus and the panels are only laid out while they show, and every +//! timed notice asks for a bounded, slow repaint only while it is up. use crate::clipboard; use crate::config::{ClientConfig, StreamSettings}; @@ -14,20 +17,25 @@ use crate::path::Path; use crate::session::Live; use crate::video; use brolink_core::api::PowerAction; -use brolink_ui::{self as ui, Tone, PALETTE as P}; +use brolink_ui::{self as ui, size, space, theme, Kv, Tone, PALETTE as P}; use egui::{ - Align, Color32, CursorIcon, Event, Frame, Id, Layout, Margin, Pos2, Rect, RichText, Vec2, + Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Pos2, Rect, RichText, Vec2, ViewportCommand, }; use semver::Version; use std::time::{Duration, Instant}; -const BAR: f32 = 40.0; -/// Below this width the right-hand controls collapse into a real egui More -/// menu so they cannot overlap Disconnect or the PC name. -const OVERFLOW_BELOW: f32 = 1200.0; +/// Below this width the toolbar's menus fold into one More menu, so they +/// cannot overlap Disconnect or the machine's name. +const OVERFLOW_BELOW: f32 = 1040.0; +/// Below this width the toolbar drops the host-key hint. +const HINT_BELOW: f32 = 1200.0; +/// Below this width the toolbar drops the stream facts after the name. +const FACTS_BELOW: f32 = 1560.0; const STATUS: f32 = 24.0; const TOAST_FOR: Duration = Duration::from_secs(5); +/// How long the host-key hint stays up when a stream starts. +const HINT_FOR: Duration = Duration::from_secs(6); /// Each side of an overlay frame (its inner margin) and the picture it /// leaves visible beyond that. const OVERLAY_PAD: f32 = 12.0; @@ -38,6 +46,28 @@ fn overlay_width(max: f32, screen_w: f32) -> f32 { max.min(screen_w - 4.0 * OVERLAY_PAD).max(0.0) } +/// The host key as printed on this keyboard, and as written in a sentence. +pub fn host_key() -> (&'static [&'static str], &'static str) { + if cfg!(target_os = "macos") { + (&["control", "option"], "Control-Option") + } else { + (&["Ctrl", "Alt"], "Ctrl+Alt") + } +} + +/// What to call the other machine in the toolbar: its power menu reads +/// "PC" for Windows, as the lobby's messages expect. +fn machine_noun(os: &str) -> &'static str { + let os = os.to_ascii_lowercase(); + if os.starts_with("windows") { + "PC" + } else if os.starts_with("macos") || os.starts_with("darwin") { + "Mac" + } else { + "Machine" + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Action { Disconnect, @@ -61,6 +91,8 @@ pub struct Env<'a> { pub fullscreen: bool, /// The path as discovery sees it now; fresher than `live.path`. pub path: Option, + /// The other machine's Tailscale OS string, for naming its menu. + pub os: String, /// The PC's host version when it cannot take updates over the network, /// and the release that could be installed through the stream. pub old_host: Option<(Version, Option)>, @@ -80,7 +112,7 @@ pub struct View { /// The mouse is captured: hidden, held in place, raw movement sent. captured: bool, grabbed: bool, - /// Ctrl+Alt dropped the toolbar over the picture. + /// The host key dropped the toolbar over the picture. bar_shown: bool, stats: bool, settings: Option, @@ -94,12 +126,18 @@ pub struct View { poor: bool, poor_hinted: bool, toasts: Vec, + /// When the stream started, for the host-key hint. + hint_since: Option, /// Pastes seen through `clipboard.pastes_done()`. pastes_seen: u32, /// Ctrl+Alt was down when capture last toggled; ignore further edges /// until both keys are up. `release_all` clears `Held.modifiers`, so /// deriving "was down" from that retriggered the toggle every frame. capture_chord_held: bool, + /// A video problem to show in place of the session's, for rendering + /// the recovery overlay without a broken capture. + #[cfg(test)] + problem: Option, } impl Default for View { @@ -113,14 +151,17 @@ impl Default for View { held: Held::default(), scroll: (0.0, 0.0), motion: (0.0, 0.0), - bar_h: BAR, + bar_h: size::TOOLBAR, confirm: None, confirm_install: false, poor: false, poor_hinted: false, toasts: Vec::new(), + hint_since: None, pastes_seen: 0, capture_chord_held: false, + #[cfg(test)] + problem: None, } } } @@ -129,9 +170,10 @@ impl View { pub fn set_poor(&mut self, poor: bool) { if poor && !self.poor && !self.poor_hinted { self.poor_hinted = true; + let (_, keys) = host_key(); self.toast( - Tone::Danger, - "Video is arriving unevenly. Open Stats to check loss and decode time, or lower the bitrate in Stream settings.", + Tone::Warning, + format!("Video is arriving unevenly. {keys} opens the toolbar: Stats shows loss and decode time, and Stream settings can lower the bitrate."), ); } self.poor = poor; @@ -164,6 +206,7 @@ impl View { self.poor = false; self.poor_hinted = false; self.toasts.clear(); + self.hint_since = None; self.capture_chord_held = false; } @@ -202,19 +245,20 @@ impl View { }; // The picture fills the window, in its own proportions; the - // toolbar, when Ctrl+Alt has asked for it, lies over the top of it. - // A menu or a question keeps it there until it is answered. + // toolbar, when the host key has asked for it, lies over the top of + // it. A menu or a question keeps it there until it is answered. let video = fit(screen, vw / vh); let bottom_gap = screen.bottom() - video.bottom(); let popup = ctx.memory(|m| m.any_popup_open()); let bar_visible = self.bar_shown || popup || self.confirm.is_some() || self.confirm_install; let mut bar_rect = bar_visible .then(|| Rect::from_min_size(screen.min, Vec2::new(screen.width(), self.bar_h))); + let waiting = live.frames.seq() == 0; egui::CentralPanel::default() .frame(Frame::new().fill(Color32::BLACK)) .show(ctx, |ui| { - if live.frames.seq() > 0 { + if !waiting { ui.painter().add(egui::Shape::Callback( egui_wgpu::Callback::new_paint_callback( video, @@ -223,45 +267,28 @@ impl View { }, ), )); - } else { - ui.painter().rect_filled(video, 0.0, P.bg); - let c = video.center(); - ui.put( - Rect::from_center_size(c, Vec2::new(300.0, 60.0)), - ui::spinner(22.0, P.accent), - ); - ui.painter().text( - c + Vec2::new(0.0, 36.0), - egui::Align2::CENTER_CENTER, - if live.session.connected() { - format!("Waiting for video from {}…", live.pc) - } else { - format!("Connecting to {}…", live.pc) - }, - egui::FontId::proportional(15.0), - P.muted, - ); - ui.painter().text( - c + Vec2::new(0.0, 60.0), - egui::Align2::CENTER_CENTER, - format!("{} · {}", live.path.label(), live.settings.describe()), - egui::FontId::proportional(12.5), - P.faint, - ); } if bottom_gap >= STATUS || self.stats { self.status_line(ui, screen, video, env, &stats, bottom_gap >= STATUS); } }); + if waiting { + self.waiting(ctx, screen, env, &mut actions); + } if let Some(rect) = bar_rect { let measured = self.toolbar(ctx, rect, env, &mut actions); - self.bar_h = measured.height().max(BAR); + self.bar_h = measured.height().max(size::TOOLBAR); bar_rect = Some(Rect::from_min_size( screen.min, Vec2::new(screen.width(), self.bar_h), )); } + #[cfg(test)] + let stats = brolink_stream::Stats { + video_problem: stats.video_problem.clone().or(self.problem.clone()), + ..stats + }; self.toasts( ctx, screen, @@ -271,91 +298,191 @@ impl View { &mut actions, ); - self.settings_panel(ctx, env, &mut actions); + // Panels keep clear of the toolbar while it shows. + let below_bar = bar_rect + .map(|b| Rect::from_min_max(Pos2::new(screen.left(), b.bottom()), screen.max)) + .unwrap_or(screen); + self.settings_panel(ctx, below_bar, &mut actions); if self.stats { - self.diagnostics(ctx, env, &stats); + self.diagnostics(ctx, env, below_bar, &stats); } self.input(ctx, live, env.cfg, video, bar_rect); actions } - fn settings_panel(&mut self, ctx: &egui::Context, env: &Env<'_>, actions: &mut Vec) { + /// Before the first picture: what is happening, and a way out that does + /// not need the host key. + fn waiting( + &mut self, + ctx: &egui::Context, + screen: Rect, + env: &Env<'_>, + actions: &mut Vec, + ) { + let live = env.live; + let w = overlay_width(420.0, screen.width()); + egui::Area::new(Id::new("stream-waiting")) + .order(egui::Order::Middle) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.set_width(w); + ui.vertical_centered(|ui| { + ui.spacing_mut().item_spacing.y = space::SM; + ui.add(ui::spinner(20.0, P.accent)); + ui.add_space(space::XS); + ui.label( + RichText::new(if live.session.connected() { + format!("Waiting for the first picture from {}", live.pc) + } else { + format!("Connecting to {}", live.pc) + }) + .font(theme::medium(theme::text::BODY + 1.0)) + .color(P.text), + ); + let path = env.path.as_ref().unwrap_or(&live.path); + ui.label( + RichText::new(format!("{} · {}", path.label(), live.settings.describe())) + .font(theme::mono(theme::text::MONO - 0.5)) + .color(P.text_tertiary), + ); + ui.add_space(space::SM); + if ui::secondary_button(ui, "Cancel").clicked() { + actions.push(Action::Disconnect); + } + }); + }); + } + + fn settings_panel(&mut self, ctx: &egui::Context, region: Rect, actions: &mut Vec) { let Some(settings) = self.settings.as_mut() else { return; }; - let mut open = true; let mut apply = false; - let mut cancel = false; + let mut close = false; + let w = overlay_width(520.0, region.width()) - 2.0 * space::LG; egui::Window::new("Stream settings") - .open(&mut open) + .constrain_to(region) + .title_bar(false) + .frame(ui::panel_frame()) .collapsible(false) .resizable(false) - .default_width(520.0) - .max_width((ctx.screen_rect().width() - 48.0).max(280.0)) - .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .default_width(w) + .max_width(w) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { - egui::ScrollArea::vertical() - .max_height((ctx.screen_rect().height() - 150.0).max(120.0)) - .show(ui, |ui| { + ui.set_width(w); + close = ui::panel_header(ui, "Stream settings"); + ui::clipped_scroll( + ui, + "stream-settings", + (region.height() - 190.0).max(120.0), + |ui| { + ui.spacing_mut().item_spacing.y = 0.0; crate::settings::stream_controls( ui, settings, crate::app::ClientApp::native_pixels(ctx), ); - }); - ui::row_separator(ui); - ui::caption(ui, "Applying reconnects the stream with these settings."); - ui.horizontal(|ui| { + }, + ); + ui.add_space(space::MD); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = space::SM; apply = ui::primary_button(ui, "Apply and reconnect").clicked(); - cancel = ui::ghost_button(ui, "Cancel").clicked(); + if ui::ghost_button(ui, "Cancel").clicked() { + close = true; + } }); + ui::small_print( + ui, + "Applying restarts the stream with these settings, in a second or two.", + ); }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !ctx.memory(|m| m.any_popup_open()) { + close = true; + } if apply { actions.push(Action::ApplySettings(settings.clone())); } - if !open || apply || cancel { + if close || apply { self.settings = None; } - let _ = env; } - fn diagnostics(&mut self, ctx: &egui::Context, env: &Env<'_>, stats: &brolink_stream::Stats) { + fn diagnostics( + &mut self, + ctx: &egui::Context, + env: &Env<'_>, + region: Rect, + stats: &brolink_stream::Stats, + ) { let live = env.live; - egui::Window::new("Stream performance").open(&mut self.stats) - .resizable(false).collapsible(false).default_pos(Pos2::new(16.0, 60.0)).default_width(330.0) + let mut close = false; + egui::Window::new("Stream performance") + .constrain_to(region) + .title_bar(false) + .frame(ui::panel_frame()) + .resizable(false) + .collapsible(false) + .default_pos(Pos2::new(16.0, self.bar_h + 16.0)) + .default_width(340.0) + .max_width(340.0) .show(ctx, |ui| { - let path = env.path.as_ref().unwrap_or(&live.path); - ui::status_pill(ui, &path.label(), path.tone()); - ui::caption(ui, live.quality_label()); - ui.add_space(8.0); - egui::Grid::new("stream-metrics").num_columns(2).spacing([18.0, 10.0]).show(ui, |ui| { - for (name, value) in [ - ("Stream resolution", format!("{} × {}", stats.width, stats.height)), - ("Frames decoded", format!("{:.1} / {} fps", stats.fps, live.requested.2)), - ("Video received", format!("{:.2} Mbps", stats.mbps)), - ("Bitrate target", format!("{} Mbps", live.settings.bitrate_kbps / 1000)), - ("Network round trip", format!("{} ± {} ms", stats.rtt_ms, stats.rtt_var_ms)), - ("Packet loss", format!("{:.2}%", stats.loss_pct)), - ("Host processing", format!("{:.2} ms", stats.host_ms)), - ("Frame assembly", format!("{:.2} ms", stats.assembly_ms)), - ("Decoder queue", format!("{:.2} ms", stats.queue_ms)), - ("Decode time", format!("{:.2} ms", stats.decode_ms)), - ("Decoder", stats.decoder.to_string()), - ] { - ui::caption(ui, name); - ui.label(RichText::new(value).color(P.text)); - ui.end_row(); + ui.set_width(340.0); + close = ui::panel_header(ui, "Stream performance"); + // Everything under the title scrolls when the window is too + // short for it, so the panel always ends inside the window. + let room = ui::room_below(ui, space::LG + 1.0); + ui::clipped_scroll(ui, "stream-performance", room, |ui| { + let path = env.path.as_ref().unwrap_or(&live.path); + ui.horizontal_wrapped(|ui| { + ui::tag(ui, Some(path.tone()), &path.label()); + ui::tag(ui, None, &profile_label(&live.settings)); + }); + ui.add_space(space::SM); + let rows = [ + Kv::new("Resolution", format!("{} × {}", stats.width, stats.height)), + Kv::new( + "Frame rate", + format!("{:.1} of {} fps", stats.fps, live.requested.2), + ), + Kv::new( + "Bitrate", + format!( + "{:.1} of {} Mbps", + stats.mbps, + live.settings.bitrate_kbps / 1000 + ), + ), + Kv::new( + "Round trip", + format!("{} ± {} ms", stats.rtt_ms, stats.rtt_var_ms), + ), + Kv::new("Packet loss", format!("{:.2}%", stats.loss_pct)), + Kv::new("Encode on host", format!("{:.2} ms", stats.host_ms)), + Kv::new("Frame assembly", format!("{:.2} ms", stats.assembly_ms)), + Kv::new("Decoder queue", format!("{:.2} ms", stats.queue_ms)), + Kv::new("Decode", format!("{:.2} ms", stats.decode_ms)), + Kv::new("Decoder", stats.decoder.to_string()), + ] + .map(Kv::mono); + ui::kv_grid(ui, &rows); + ui.add_space(space::SM); + ui::small_print(ui, "The encoder aims at the bitrate while the picture changes; a still screen needs less. Received bitrate falling well short during motion, or swinging, means packets are being lost on the way."); + if !stats.audio.is_empty() { + ui::small_print(ui, &stats.audio); + } + ui.add_space(space::XS); + if ui::secondary_button(ui, "Copy diagnostics").clicked() { + ctx.copy_text(format!("BroLink {}\n{}\nRequested: {} × {}, {} fps, {} Mbps\n{stats:#?}", + env!("CARGO_PKG_VERSION"), path.label(), live.requested.0, live.requested.1, + live.requested.2, live.settings.bitrate_kbps / 1000)); } }); - ui.add_space(8.0); - ui::caption(ui, "The PC's encoder aims at the target (CBR) and reaches it when the picture is changing; a still screen needs fewer bits. Received falling well short during motion, or swinging, means the path is dropping packets."); - if !stats.audio.is_empty() { ui::caption(ui, &stats.audio); } - if ui::ghost_button(ui, "Copy diagnostics").clicked() { - ctx.copy_text(format!("BroLink {}\n{}\nRequested: {} × {}, {} fps, {} Mbps\n{stats:#?}", - env!("CARGO_PKG_VERSION"), path.label(), live.requested.0, live.requested.1, - live.requested.2, live.settings.bitrate_kbps / 1000)); - } }); + if close { + self.stats = false; + } } fn toolbar( @@ -368,9 +495,7 @@ impl View { let live = env.live; let cfg = env.cfg; let overflow = rect.width() < OVERFLOW_BELOW; - let frame = ui::overlay_frame() - .corner_radius(0) - .inner_margin(Margin::symmetric(12, 0)); + let facts = rect.width() >= FACTS_BELOW; let path = env.path.clone().unwrap_or_else(|| live.path.clone()); let bar = egui::Area::new(Id::new("stream-toolbar")) .fixed_pos(rect.min) @@ -379,99 +504,113 @@ impl View { .show(ctx, |ui| { ui.set_min_width(rect.width()); ui.set_max_width(rect.width()); - ui.set_min_height(BAR); - frame.show(ui, |ui| { - ui.set_min_height(BAR); - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - let s = live.session.stats(); - let tone = if !live.session.connected() { - Tone::Accent - } else if self.poor || s.video_problem.is_some() { - Tone::Danger - } else { - Tone::Success - }; - ui.label(RichText::new("●").size(9.0).color(tone.color())); - ui.label( - RichText::new(&live.pc) - .font(brolink_ui::theme::medium(13.5)) - .color(P.text), - ); - if !overflow { - let (w, h) = if s.width > 0 { - (s.width, s.height) + Frame::new() + .fill(P.overlay) + .inner_margin(Margin::symmetric(space::MD as i8, 0)) + .show(ui, |ui| { + ui.set_min_height(size::TOOLBAR); + ui.spacing_mut().interact_size.y = size::CONTROL_SM; + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = space::SM; + let s = live.session.stats(); + let tone = if !live.session.connected() { + Tone::Accent + } else if self.poor || s.video_problem.is_some() { + Tone::Warning } else { - (live.requested.0, live.requested.1) + Tone::Success }; - ui::caption( - ui, - format!("{w}×{h} · {} fps · {}", live.requested.2, live.codec), + ui::status_dot(ui, tone); + ui.add( + egui::Label::new( + RichText::new(&live.pc) + .font(theme::medium(theme::text::BODY)) + .color(P.text), + ) + .truncate(), ); - ui.add_space(2.0); - let pill = ui::status_pill(ui, &path.label(), path.tone()); - if path.relayed() { - pill.on_hover_text( - "This is the route currently in use. A relay does not impose a bitrate limit in BroLink.", + if facts { + let (w, h) = if s.width > 0 { + (s.width, s.height) + } else { + (live.requested.0, live.requested.1) + }; + ui.label( + RichText::new(format!( + "{w}×{h} · {} fps · {}", + live.requested.2, live.codec + )) + .font(theme::mono(theme::text::MONO - 0.5)) + .color(P.text_tertiary), ); - } else if path.direct == Some(true) { - pill.on_hover_text("Packets go straight to the PC."); - } - ui::caption(ui, "Ctrl+Alt hides this bar"); - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui::danger_button(ui, "Disconnect").clicked() { - actions.push(Action::Disconnect); } - if ui::ghost_button(ui, "Stream settings").clicked() { - self.held.release_all(&live.input); - self.set_captured(ctx, false); - self.settings = Some(cfg.stream.clone()); + if !overflow { + let tag = ui::tag(ui, Some(path.tone()), &path.label()); + if path.relayed() { + tag.on_hover_text("The route in use right now. A relay adds delay; BroLink never lowers the bitrate for it."); + } else if path.direct == Some(true) { + tag.on_hover_text("Packets go straight to the machine."); + } } - if overflow { - self.more_menu(ui, ctx, env, actions); - } else { - self.pc_menu(ui, env, actions); - self.fullscreen_button(ui, env, actions); - self.stats_button(ui); - self.keys_menu(ui, live, cfg, actions); - self.mouse_menu(ui, ctx, live, cfg, actions); + if rect.width() >= HINT_BELOW { + ui.add_space(space::XS); + let (keys, _) = host_key(); + ui::shortcut(ui, keys, "hides this bar"); } + ui::trailing(ui, |ui| { + ui.spacing_mut().item_spacing.x = space::XS; + if overflow { + self.more_menu(ui, ctx, env, actions); + } else { + self.mouse_menu(ui, ctx, live, cfg, actions); + self.keys_menu(ui, live, cfg, actions); + self.stats_button(ui); + self.fullscreen_button(ui, env, actions); + self.power_menu(ui, env, actions); + } + if ui::ghost_button(ui, "Stream settings").clicked() { + self.held.release_all(&live.input); + self.set_captured(ctx, false); + self.settings = Some(cfg.stream.clone()); + } + toolbar_rule(ui); + if ui::danger_button(ui, "Disconnect").clicked() { + actions.push(Action::Disconnect); + } + }); }); }); - }); + let r = ui.min_rect(); + ui.painter().hline( + r.x_range(), + r.bottom() - 0.5, + theme::stroke(1.0, Color32::from_white_alpha(28)), + ); }); if let Some(action) = self.confirm { - let w = overlay_width(440.0, rect.width()); - egui::Area::new(Id::new("stream-confirm")) - .fixed_pos(Pos2::new( - rect.center().x - w / 2.0 - OVERLAY_PAD, - rect.bottom() + 8.0, - )) - .order(egui::Order::Foreground) - .show(ctx, |ui| { - ui::overlay_frame().show(ui, |ui| { - ui.set_width(w); - ui.label( - RichText::new(format!( - "{} {}? Programs are closed without asking; anything unsaved is lost.", - action.label(), - live.pc - )) - .color(P.text), - ); - ui.horizontal(|ui| { - if ui::toned_button(ui, action.label(), Tone::Danger).clicked() { - actions.push(Action::Power(action)); - self.confirm = None; - } - if ui::ghost_button(ui, "Cancel").clicked() { - self.confirm = None; - } - }); - }); + let w = overlay_width(420.0, rect.width()); + question(ctx, "stream-confirm", rect, w, |ui| { + ui.label( + RichText::new(format!("{} {}?", action.label(), live.pc)) + .font(theme::semibold(theme::text::TITLE)) + .color(P.text), + ); + ui::muted( + ui, + "The stream ends first. Programs there close without asking, and anything unsaved is lost.", + ); + ui.add_space(space::XS); + ui.horizontal(|ui| { + if ui::destructive_button(ui, action.label()).clicked() { + actions.push(Action::Power(action)); + self.confirm = None; + } + if ui::ghost_button(ui, "Cancel").clicked() { + self.confirm = None; + } }); + }); } if self.confirm_install { let (running, latest) = env @@ -479,43 +618,42 @@ impl View { .clone() .map(|(r, l)| (r.to_string(), l.map(|v| v.to_string()))) .unwrap_or_default(); - let w = overlay_width(500.0, rect.width()); - egui::Area::new(Id::new("stream-install")) - .fixed_pos(Pos2::new( - rect.center().x - w / 2.0 - OVERLAY_PAD, - rect.bottom() + 8.0, - )) - .order(egui::Order::Foreground) - .show(ctx, |ui| { - ui::overlay_frame().show(ui, |ui| { - ui.set_width(w); - ui.label( - RichText::new(format!("Install BroLink Host {} on {}", latest.as_deref().unwrap_or("(latest)"), live.pc)) - .font(brolink_ui::theme::medium(14.0)) - .color(P.text), - ); - ui.label( - RichText::new(format!( - "{} runs {running}, which cannot take updates over the network. This Mac will press Win+R on the PC, type one line that fetches the new version from this Mac over Tailscale, and press Enter. A PowerShell window appears on the PC, swaps the file in and restarts the service; the stream is not interrupted. The PC's desktop must be unlocked and in front.", - live.pc - )) - .color(P.muted), - ); - ui.horizontal(|ui| { - if ui::primary_button(ui, "Install through the stream").clicked() { - actions.push(Action::InstallHost); - self.confirm_install = false; - } - if ui::ghost_button(ui, "Cancel").clicked() { - self.confirm_install = false; - } - }); - }); + let w = overlay_width(480.0, rect.width()); + question(ctx, "stream-install", rect, w, |ui| { + ui.label( + RichText::new(format!( + "Install BroLink Host {} on {}?", + latest.as_deref().unwrap_or("(latest)"), + live.pc + )) + .font(theme::semibold(theme::text::TITLE)) + .color(P.text), + ); + ui::muted( + ui, + format!( + "{} runs {running}, which can't take updates over the network. BroLink presses Win+R on it, types one line that fetches the new version from this machine over Tailscale, and presses Enter. A PowerShell window opens there, swaps the file and restarts the service; the stream stays up.", + live.pc + ), + ); + ui::small_print(ui, "Its desktop must be unlocked and in front."); + ui.add_space(space::XS); + ui.horizontal(|ui| { + if ui::primary_button(ui, "Install through the stream").clicked() { + actions.push(Action::InstallHost); + self.confirm_install = false; + } + if ui::ghost_button(ui, "Cancel").clicked() { + self.confirm_install = false; + } }); + }); } bar.response.rect } + /// The narrow toolbar's one menu: everything but Stream settings and + /// Disconnect, in labelled groups, scrolling if the window is short. fn more_menu( &mut self, ui: &mut egui::Ui, @@ -525,39 +663,21 @@ impl View { ) { let live = env.live; let cfg = env.cfg; - let response = ui::ghost_button(ui, "More"); - let popup_id = Id::new("stream-more"); - if response.clicked() { - ui.memory_mut(|m| m.toggle_popup(popup_id)); - } - egui::popup::popup_below_widget( - ui, - popup_id, - &response, - egui::popup::PopupCloseBehavior::CloseOnClickOutside, - |ui| { - ui.set_min_width(180.0); - self.mouse_menu(ui, ctx, live, cfg, actions); - self.keys_menu(ui, live, cfg, actions); - if ui - .button(if self.stats { "Hide stats" } else { "Stats" }) - .clicked() - { - self.stats = !self.stats; - ui.memory_mut(|m| m.close_popup()); - } - let fs = if env.fullscreen { - "Exit full screen" - } else { - "Full screen" - }; - if ui.button(fs).clicked() { - actions.push(Action::Fullscreen(!env.fullscreen)); - ui.memory_mut(|m| m.close_popup()); - } - self.pc_menu(ui, env, actions); - }, - ); + ui::menu_button(ui, "More", |ui| { + ui::menu_label(ui, "View"); + if ui::menu_choice(ui, "Full screen", env.fullscreen).clicked() { + actions.push(Action::Fullscreen(!env.fullscreen)); + } + if ui::menu_choice(ui, "Stream performance", self.stats).clicked() { + self.stats = !self.stats; + } + ui::menu_label(ui, "Mouse"); + self.mouse_items(ui, ctx, live, cfg, actions); + ui::menu_label(ui, "Send keys"); + self.key_items(ui, live, cfg, actions); + ui::menu_label(ui, machine_noun(&env.os)); + self.power_items(ui, env, actions); + }); } fn fullscreen_button(&self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { @@ -577,44 +697,41 @@ impl View { fn stats_button(&mut self, ui: &mut egui::Ui) { if ui::ghost_button(ui, if self.stats { "Hide stats" } else { "Stats" }) - .on_hover_text( - "Show received and target bitrate, frame rate, network delay and decoder performance", - ) + .on_hover_text("Frame rate, bitrate, round trip, packet loss and decode time") .clicked() { self.stats = !self.stats; } } - fn pc_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { - ui::menu_button(ui, "PC", |ui| { - if ui.button("Sleep").clicked() { - actions.push(Action::Power(PowerAction::Sleep)); - ui.close_menu(); - } - if ui.button("Restart…").clicked() { - self.confirm = Some(PowerAction::Restart); - ui.close_menu(); - } - if ui.button("Shut down…").clicked() { - self.confirm = Some(PowerAction::Shutdown); - ui.close_menu(); - } - if let Some((running, _)) = &env.old_host { - ui.separator(); - let label = format!("Update BroLink Host… (runs {running})"); - if ui - .add_enabled(env.handover.is_none(), egui::Button::new(label)) - .on_hover_text("Installs the newest BroLink Host on the PC through this stream, so future updates arrive by themselves.") - .clicked() - { - self.confirm_install = true; - ui.close_menu(); - } - } + fn power_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { + ui::menu_button(ui, machine_noun(&env.os), |ui| { + self.power_items(ui, env, actions) }); } + fn power_items(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { + if ui::menu_item(ui, "Sleep").clicked() { + actions.push(Action::Power(PowerAction::Sleep)); + } + if ui::menu_item(ui, "Restart…").clicked() { + self.confirm = Some(PowerAction::Restart); + } + if ui::menu_item(ui, "Shut down…").clicked() { + self.confirm = Some(PowerAction::Shutdown); + } + if let Some((running, _)) = &env.old_host { + ui::menu_separator(ui); + let label = format!("Update BroLink Host… (runs {running})"); + if ui::menu_item_enabled(ui, env.handover.is_none(), &label) + .on_hover_text("Installs the newest BroLink Host through this stream, so later updates arrive by themselves.") + .clicked() + { + self.confirm_install = true; + } + } + } + fn keys_menu( &mut self, ui: &mut egui::Ui, @@ -623,99 +740,108 @@ impl View { actions: &mut Vec, ) { ui::menu_button(ui, "Keys", |ui| { - let input = &live.input; - for (label, keys) in [ - ( - "Ctrl+Alt+Del", - &[input::VK_CONTROL, input::VK_MENU, input::VK_DELETE][..], - ), - ("Windows key", &[input::VK_LWIN][..]), - ("Alt+Tab", &[input::VK_MENU, input::VK_TAB][..]), - ("Esc", &[input::VK_ESCAPE][..]), - ("Print Screen", &[input::VK_SNAPSHOT][..]), - ] { - if ui.button(label).clicked() { - self.held.chord(input, keys); - ui.close_menu(); - } - } - ui.separator(); - let cmd = if cfg.cmd_is_ctrl { - "● ⌘ acts as Ctrl (⌘C, ⌘V, ⌘Z work)" - } else { - "○ ⌘ acts as Ctrl (now the Windows key)" - }; - if ui - .button(cmd) - .on_hover_text("Click to switch what the Command key does on the PC") - .clicked() - { - actions.push(Action::ToggleCmd); - ui.close_menu(); - } - ui.separator(); - ui::caption( + ui::menu_label(ui, "Send to the other machine"); + self.key_items(ui, live, cfg, actions); + let (_, keys) = host_key(); + ui::menu_note( ui, - "Everything else goes to the PC as pressed. Ctrl+Alt toggles mouse capture.", + &format!("Everything else you type goes there as pressed. {keys} shows or hides this bar."), ); if live.clipboard.unsupported() { - ui::caption( + ui::menu_note( ui, - "Clipboard sync needs BroLink Host 3.1 on the PC (PC → Update BroLink Host).", + "Clipboard sync needs BroLink Host 3.1 or later there (PC menu → Update BroLink Host).", ); } else { - ui::caption( + ui::menu_note( ui, - "⌘C on the PC copies to this Mac; ⌘V pastes this Mac's text.", + "Copy there and paste here, or the other way round: the clipboard follows you.", ); } }); } - fn mouse_menu( + fn key_items( &mut self, ui: &mut egui::Ui, - ctx: &egui::Context, live: &Live, cfg: &ClientConfig, actions: &mut Vec, ) { - let label = if cfg.capture_mouse { - "Mouse: captured" - } else { - "Mouse: free" - }; - ui::menu_button(ui, label, |ui| { - let mark = |on: bool| if on { "● " } else { " " }; - if ui - .button(format!( - "{}Captured · raw movement, which games read. A click on the picture captures; Ctrl+Alt frees", - mark(cfg.capture_mouse) - )) - .clicked() - { - if !cfg.capture_mouse { - actions.push(Action::MouseCapture(true)); - } - ui.close_menu(); + let input = &live.input; + for (label, keys) in [ + ( + "Ctrl+Alt+Delete", + &[input::VK_CONTROL, input::VK_MENU, input::VK_DELETE][..], + ), + ("Windows key", &[input::VK_LWIN][..]), + ("Alt+Tab", &[input::VK_MENU, input::VK_TAB][..]), + ("Escape", &[input::VK_ESCAPE][..]), + ("Print Screen", &[input::VK_SNAPSHOT][..]), + ] { + if ui::menu_item(ui, label).clicked() { + self.held.chord(input, keys); } - if ui - .button(format!( - "{}Free · the Mac cursor's position is sent, 1:1 on the PC", - mark(!cfg.capture_mouse) - )) + } + if cfg!(target_os = "macos") { + ui::menu_separator(ui); + if ui::menu_choice(ui, "Command acts as Ctrl", cfg.cmd_is_ctrl) + .on_hover_text("On: Command-C, V and Z copy, paste and undo there. Off: Command is the Windows key.") .clicked() { - if cfg.capture_mouse { - actions.push(Action::MouseCapture(false)); - self.release(ctx, live); - } - ui.close_menu(); + actions.push(Action::ToggleCmd); } + } + } + + fn mouse_menu( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + live: &Live, + cfg: &ClientConfig, + actions: &mut Vec, + ) { + ui::menu_button(ui, "Mouse", |ui| { + self.mouse_items(ui, ctx, live, cfg, actions); + let (_, keys) = host_key(); + ui::menu_note( + ui, + &format!( + "Captured: a click on the picture takes the mouse and {keys} gives it back." + ), + ); }); } - /// Short notices under the toolbar: clipboard, connection, an install. + fn mouse_items( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + live: &Live, + cfg: &ClientConfig, + actions: &mut Vec, + ) { + if ui::menu_choice(ui, "Captured, for games (raw movement)", cfg.capture_mouse).clicked() + && !cfg.capture_mouse + { + actions.push(Action::MouseCapture(true)); + } + if ui::menu_choice( + ui, + "Free, for desktops (follows this cursor)", + !cfg.capture_mouse, + ) + .clicked() + && cfg.capture_mouse + { + actions.push(Action::MouseCapture(false)); + self.release(ctx, live); + } + } + + /// Short notices under the toolbar: the host-key hint, clipboard, + /// connection, an install. fn toasts( &mut self, ctx: &egui::Context, @@ -726,60 +852,78 @@ impl View { actions: &mut Vec, ) { self.toasts.retain(|t| t.at.elapsed() < TOAST_FOR); + let hint = self + .hint_since + .is_some_and(|t| t.elapsed() < HINT_FOR && !self.bar_shown); + if !hint { + self.hint_since = None; + } let mut lines: Vec<(Tone, String)> = self .toasts .iter() .map(|t| (t.tone, t.text.clone())) .collect(); if let Some(h) = &env.handover { - lines.push((Tone::Info, h.clone())); + lines.push((Tone::Neutral, h.clone())); } if let Some(n) = env.live.clipboard.note() { lines.push((Tone::Neutral, n)); } - if lines.is_empty() && video_problem.is_none() { + if lines.is_empty() && video_problem.is_none() && !hint { return; } + // Slow and bounded: enough to take a notice down on time. ctx.request_repaint_after(Duration::from_millis(500)); - let top = bar.map(|b| b.bottom()).unwrap_or(screen.top()) + 10.0; - let w = overlay_width(496.0, screen.width()); + let top = bar.map(|b| b.bottom()).unwrap_or(screen.top()) + space::MD; + let w = overlay_width(480.0, screen.width()); egui::Area::new(Id::new("stream-toasts")) .fixed_pos(Pos2::new(screen.center().x - w / 2.0 - OVERLAY_PAD, top)) .order(egui::Order::Foreground) .interactable(video_problem.is_some()) .show(ctx, |ui| { ui.set_width(w + 2.0 * OVERLAY_PAD); + ui.spacing_mut().item_spacing.y = space::SM; + if hint { + ui::overlay_frame().show(ui, |ui| { + ui.set_width(w); + let (keys, _) = host_key(); + ui::shortcut(ui, keys, "releases the mouse and shows the toolbar"); + }); + } if let Some(problem) = video_problem { + let help = env.video_help.as_ref(); + // Once the machine has answered, "asking why" is stale. + let headline = match help { + Some(_) => problem.trim_end_matches("Asking the PC why…").trim_end(), + None => problem, + }; ui::overlay_frame().show(ui, |ui| { ui.set_width(w); - ui.label(RichText::new(problem).color(P.text)); - // The PC's own answer, once it has given one. - if let Some(help) = env.video_help.as_ref() { - ui.add_space(6.0); - ui.label(RichText::new(&help.message).color(P.muted)); + ui.spacing_mut().item_spacing.y = space::XS; + ui.add( + egui::Label::new( + RichText::new(headline) + .font(theme::medium(theme::text::BODY)) + .color(P.text), + ) + .wrap(), + ); + // The machine's own answer, once it has given one. + if let Some(help) = help { + ui::muted(ui, &help.message); } + ui.add_space(space::XS); ui.horizontal(|ui| { - if ui::ghost_button(ui, "Restart stream").clicked() { - actions.push(Action::RestartStream); - } - let Some(help) = env.video_help.as_ref() else { - return; - }; - if !help.hdr_is_on { - return; + if let Some(h) = help.filter(|h| h.hdr_is_on) { + let label = format!("Turn off {} there", h.mode); + if h.busy { + ui::empty_state(ui, &format!("Turning {} off…", h.mode), true); + } else if ui::primary_button(ui, &label).clicked() { + actions.push(Action::TurnOffHdr); + } } - if help.busy { - ui.label( - RichText::new(format!("Turning {} off…", help.mode)) - .color(P.muted), - ); - } else if ui::ghost_button( - ui, - &format!("Turn off {} on the PC", help.mode), - ) - .clicked() - { - actions.push(Action::TurnOffHdr); + if ui::secondary_button(ui, "Restart stream").clicked() { + actions.push(Action::RestartStream); } }); }); @@ -787,10 +931,7 @@ impl View { for (tone, text) in lines { ui::overlay_frame().show(ui, |ui| { ui.set_width(w); - ui.horizontal_wrapped(|ui| { - ui.label(RichText::new("●").size(9.0).color(tone.color())); - ui.label(RichText::new(text).color(P.text)); - }); + ui::dot_label(ui, tone, &text); }); } }); @@ -807,41 +948,43 @@ impl View { ) { let live = env.live; let secs = live.started.elapsed().as_secs(); - let mut text = format!( - "{:02}:{:02}:{:02}", + let text = format!( + "{:02}:{:02}:{:02} {:.0}/{} fps {:.1}/{} Mbps {} ms", secs / 3600, (secs / 60) % 60, - secs % 60 - ); - text.push_str(&format!( - " {:.0} / {} fps {:.1} / {} Mbps {} ms RTT", + secs % 60, stats.fps, live.requested.2, stats.mbps, live.settings.bitrate_kbps / 1000, stats.rtt_ms - )); - let font = egui::FontId::monospace(12.0); + ); + let font = theme::mono(theme::text::LABEL + 0.5); if in_gap { let y = video.bottom() + (screen.bottom() - video.bottom()) / 2.0; ui.painter().text( - Pos2::new(screen.left() + 14.0, y), - egui::Align2::LEFT_CENTER, + Pos2::new(screen.left() + space::MD, y), + Align2::LEFT_CENTER, text, font, - P.faint, + P.text_tertiary, ); } else { - let galley = ui.painter().layout_no_wrap(text, font, P.text); - let size = galley.size() + Vec2::new(20.0, 10.0); + let galley = ui.painter().layout_no_wrap(text, font, P.text_secondary); + let size = galley.size() + Vec2::new(2.0 * space::SM, space::SM); let rect = Rect::from_min_size( - Pos2::new(screen.left() + 12.0, screen.bottom() - size.y - 12.0), + Pos2::new( + screen.left() + space::MD, + screen.bottom() - size.y - space::MD, + ), size, ); - ui.painter() - .rect_filled(rect, 6.0, Color32::from_black_alpha(190)); - ui.painter() - .galley(rect.min + Vec2::new(10.0, 5.0), galley, P.text); + ui.painter().rect_filled(rect, theme::radius::SM, P.overlay); + ui.painter().galley( + rect.min + Vec2::new(space::SM, space::XS), + galley, + P.text_secondary, + ); } } @@ -863,6 +1006,7 @@ impl View { /// drops the toolbar; from the toolbar it hides it and, when capture /// is on, takes the mouse back. fn host_key(&mut self, ctx: &egui::Context, live: &Live, cfg: &ClientConfig) { + self.hint_since = None; if self.captured || !self.bar_shown { self.release(ctx, live); self.bar_shown = true; @@ -874,8 +1018,8 @@ impl View { } } - /// The stream has just connected: take the mouse if the pointer is - /// here already, and say once how to get it back. + /// The stream has just connected: take the mouse if the window has + /// focus, and say once how to get it back. pub fn stream_started(&mut self, ctx: &egui::Context, live: &Live, capture: bool) { // Capture as soon as the stream connects, whenever the window has // focus, without waiting for the pointer to be over the picture. A @@ -888,14 +1032,7 @@ impl View { if capture && focused { self.grab(ctx, live); } - self.toast( - Tone::Info, - if capture { - "Ctrl+Alt frees the mouse and shows the toolbar; a click on the picture captures it again." - } else { - "Ctrl+Alt shows and hides the toolbar." - }, - ); + self.hint_since = Some(Instant::now()); } /// Forward this frame's keyboard and mouse events to the PC. @@ -1133,7 +1270,6 @@ fn pointer_on_stream( in_video && !over_bar && !popup && !overlay && !over_overlay } -/// "Auto · Smooth" → "Auto", "Custom · Sharp" → "Sharp", else "Custom". /// Whether `host_version` is too old for `/v1/update`, with the release /// that an install through the stream would put on it. pub fn old_host( @@ -1157,11 +1293,54 @@ pub fn fit(area: Rect, aspect: f32) -> Rect { Rect::from_center_size(area.center(), Vec2::new(w.floor(), h.floor())) } +/// The quality profile a stream runs at, named as Settings names it. +fn profile_label(s: &StreamSettings) -> String { + match (s.quality, s.preset()) { + (crate::config::Quality::Auto, _) => "Recommended".into(), + (_, Some(p)) => p.label().into(), + _ => format!("Custom · {}", s.describe()), + } +} + +/// A question that floats under the toolbar, centred: restart, install. +fn question(ctx: &egui::Context, id: &str, bar: Rect, w: f32, add: impl FnOnce(&mut egui::Ui)) { + egui::Area::new(Id::new(id)) + .fixed_pos(Pos2::new( + bar.center().x - w / 2.0 - OVERLAY_PAD, + bar.bottom() + space::SM, + )) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + ui::overlay_frame().show(ui, |ui| { + ui.set_width(w); + // A long question on a short window scrolls inside its + // frame instead of running off the bottom. + let room = ui::room_below(ui, OVERLAY_PAD + 1.0); + ui::clipped_scroll(ui, id, room, |ui| { + ui.set_width(w); + ui.spacing_mut().item_spacing.y = space::SM; + add(ui) + }); + }); + }); +} + +/// A short vertical hairline between groups of toolbar controls. +fn toolbar_rule(ui: &mut egui::Ui) { + let (rect, _) = ui.allocate_exact_size(Vec2::new(1.0, 16.0), egui::Sense::hover()); + ui.painter().vline( + rect.center().x, + rect.y_range(), + theme::stroke(1.0, Color32::from_white_alpha(36)), + ); +} + #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::config::ClientConfig; use crate::session::Live; + use egui_kittest::kittest::Queryable; #[test] fn fit_letterboxes_a_16_9_stream_on_a_16_10_screen() { @@ -1217,11 +1396,29 @@ mod tests { ); } - struct Fixture { - view: View, - live: Option, - cfg: ClientConfig, - fullscreen: bool, + #[test] + fn the_power_menu_is_named_for_the_machine() { + assert_eq!(machine_noun("windows"), "PC"); + assert_eq!(machine_noun("macOS"), "Mac"); + assert_eq!(machine_noun("linux"), "Machine"); + assert_eq!(machine_noun(""), "Machine"); + } + + /// What the window tells the view, besides the stream itself. + #[derive(Default, Clone)] + pub(crate) struct Extra { + pub os: String, + pub old_host: Option<(Version, Option)>, + pub handover: Option, + pub video_help: Option, + } + + pub(crate) struct Fixture { + pub view: View, + pub live: Option, + pub cfg: ClientConfig, + pub fullscreen: bool, + pub extra: Extra, applied: bool, } @@ -1233,7 +1430,9 @@ mod tests { } } - fn dummy_live(ctx: &egui::Context) -> Live { + /// A session to an address that never answers, so the view shows its + /// toolbar and overlays with no picture behind them. + pub(crate) fn dummy_live(ctx: &egui::Context) -> Live { let (tx, rx) = std::sync::mpsc::channel(); let frames = std::sync::Arc::new(brolink_stream::FrameSlot::default()); let path = crate::path::Path { @@ -1254,8 +1453,8 @@ mod tests { brolink_stream::Settings { width: 1920, height: 1080, - fps: 30, - bitrate_kbps: 4000, + fps: 60, + bitrate_kbps: 50_000, hevc: true, remote: true, }, @@ -1277,7 +1476,7 @@ mod tests { events: rx, started: Instant::now(), codec: "HEVC", - requested: (1920, 1080, 30), + requested: (1920, 1080, 60), settings, path, clipboard: crate::clipboard::Sync::spawn(ip, ctx.clone()), @@ -1298,6 +1497,7 @@ mod tests { live, cfg, fullscreen, + extra, .. } = f; let env = Env { @@ -1305,14 +1505,15 @@ mod tests { cfg, fullscreen: *fullscreen, path: None, - old_host: None, - handover: None, - video_help: None, + os: extra.os.clone(), + old_host: extra.old_host.clone(), + handover: extra.handover.clone(), + video_help: extra.video_help.clone(), }; view.show(ctx, &env); } - /// A view with the toolbar dropped, as Ctrl+Alt leaves it. + /// A view with the toolbar dropped, as the host key leaves it. fn view_with_bar() -> View { View { bar_shown: true, @@ -1320,25 +1521,51 @@ mod tests { } } - fn harness(size: Vec2) -> egui_kittest::Harness<'static, Fixture> { - egui_kittest::Harness::builder() + pub(crate) fn fixture(view: View, extra: Extra) -> Fixture { + Fixture { + view, + live: None, + cfg: ClientConfig::default(), + fullscreen: false, + extra, + applied: false, + } + } + + fn harness_with( + size: Vec2, + ppp: f32, + gpu: bool, + f: Fixture, + ) -> egui_kittest::Harness<'static, Fixture> { + let mut b = egui_kittest::Harness::builder() .with_size(size) - .with_pixels_per_point(1.0) - .with_max_steps(2) - .build_state( - paint, - Fixture { - view: view_with_bar(), - live: None, - cfg: ClientConfig::default(), - fullscreen: false, - applied: false, + .with_pixels_per_point(ppp) + .with_max_steps(2); + if gpu { + b = b.wgpu(); + } + let mut h = b.build_state(paint, f); + h.run_steps(3); + h + } + + fn harness(size: Vec2) -> egui_kittest::Harness<'static, Fixture> { + harness_with( + size, + 1.0, + false, + fixture( + view_with_bar(), + Extra { + os: "windows".into(), + ..Default::default() }, - ) + ), + ) } fn bar_control_rects(h: &egui_kittest::Harness<'_, Fixture>) -> Vec<(String, Rect)> { - use egui_kittest::kittest::Queryable; let names = [ "Disconnect", "More", @@ -1353,9 +1580,9 @@ mod tests { ]; let mut out = Vec::new(); for name in names { - for node in h.query_all_by_label_contains(name) { + for node in h.query_all_by_label(name) { let Some(b) = node.raw_bounds() else { continue }; - if b.y0 > f64::from(BAR + 8.0) { + if b.y0 > f64::from(size::TOOLBAR + 8.0) { continue; } out.push(( @@ -1386,15 +1613,13 @@ mod tests { #[test] fn toolbar_at_640_does_not_overlap_and_keeps_disconnect() { - use egui_kittest::kittest::Queryable; - let mut h = harness(Vec2::new(640.0, 420.0)); - h.run_steps(2); + let h = harness(Vec2::new(640.0, 420.0)); assert!( h.query_by_label("Disconnect").is_some(), "Disconnect must stay reachable" ); assert!( - h.query_by_label_contains("More").is_some(), + h.query_by_label("More").is_some(), "narrow width must overflow into More" ); assert!( @@ -1404,17 +1629,16 @@ mod tests { let rects = bar_control_rects(&h); assert_no_overlap(&rects); assert!( - h.state().view.bar_h >= BAR, + h.state().view.bar_h >= size::TOOLBAR, "bar_rect is measured, got {}", h.state().view.bar_h ); + crate::app::snapshots::assert_fits(&h, 640.0); } #[test] fn more_is_a_real_egui_popup_and_consumes_clicks() { - use egui_kittest::kittest::Queryable; let mut h = harness(Vec2::new(640.0, 420.0)); - h.run_steps(2); assert!( !h.ctx.memory(|m| m.any_popup_open()), "closed More is not a popup" @@ -1425,86 +1649,509 @@ mod tests { h.ctx.memory(|m| m.any_popup_open()), "More must use egui::popup so any_popup_open is true; a Background Area would fail this" ); - assert!( - h.query_by_label("Stream settings").is_some() - || h.query_by_label_contains("Keys").is_some() - || h.query_by_label("Stats").is_some(), - "More lists the overflowed controls" - ); + for item in ["Ctrl+Alt+Delete", "Stream performance", "Sleep"] { + assert!(h.query_by_label(item).is_some(), "More lists {item}"); + } let popup_open = h.ctx.memory(|m| m.any_popup_open()); assert!(!pointer_on_stream(true, false, popup_open, false, false)); - if let Some(stats) = h.query_by_label("Stats") { - stats.simulate_click(); - h.run_steps(2); - assert!( - h.state().view.stats, - "click inside More is consumed by the popup, not forwarded" - ); - } + h.get_by_label("Stream performance").simulate_click(); + h.run_steps(2); + assert!( + h.state().view.stats, + "click inside More is consumed by the popup, not forwarded" + ); } #[test] fn toolbar_at_normal_width_keeps_controls_on_the_bar() { - use egui_kittest::kittest::Queryable; - let mut h = harness(Vec2::new(1400.0, 860.0)); - h.run_steps(2); + let h = harness(Vec2::new(1400.0, 860.0)); assert!(h.query_by_label("Disconnect").is_some()); - assert!(h.query_by_label_contains("More").is_none()); + assert!(h.query_by_label("More").is_none()); assert!(h.query_by_label("Stream settings").is_some()); - assert!(h.query_by_label_contains("Keys").is_some()); + assert!(h.query_by_label("Keys").is_some()); + assert!( + h.query_by_label("PC").is_some(), + "a Windows machine's menu reads PC" + ); assert_no_overlap(&bar_control_rects(&h)); + crate::app::snapshots::assert_fits(&h, 1400.0); } - fn save_snapshot(img: image::RgbaImage, name: &str) { - let dir = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ui-snapshots"); - std::fs::create_dir_all(&dir).expect("create snapshot dir"); - let path = dir.join(name); - img.save(&path).expect("write png"); - eprintln!("wrote {}", path.display()); + #[test] + fn the_toolbar_fits_every_width_between_the_minimum_and_full_hd() { + for w in [640.0, 800.0, 1039.0, 1040.0, 1100.0, 1299.0, 1300.0, 1920.0] { + let h = harness(Vec2::new(w, 600.0)); + assert_no_overlap(&bar_control_rects(&h)); + crate::app::snapshots::assert_fits(&h, w); + } } #[test] - #[ignore = "renders with a GPU; run on demand to review the UI"] - fn stream_toolbar_snapshot_min_width() { - let mut h = egui_kittest::Harness::builder() - .wgpu() - .with_size(Vec2::new(640.0, 420.0)) - .with_pixels_per_point(1.0) - .with_max_steps(2) - .build_state( - paint, - Fixture { - view: view_with_bar(), - live: None, - cfg: ClientConfig::default(), - fullscreen: false, - applied: false, + fn a_hidden_toolbar_puts_nothing_over_the_picture() { + let h = harness_with( + Vec2::new(1280.0, 800.0), + 1.0, + false, + fixture(View::default(), Extra::default()), + ); + assert!(h.query_by_label("Disconnect").is_none()); + assert!(h.query_by_label("Stream settings").is_none()); + } + + #[test] + fn the_settings_panel_applies_or_cancels() { + let mut view = view_with_bar(); + view.settings = Some(ClientConfig::default().stream); + let mut h = harness_with( + Vec2::new(640.0, 420.0), + 1.0, + false, + fixture(view, Extra::default()), + ); + assert!(h.query_by_label("Apply and reconnect").is_some()); + // The screen waiting for the first picture lies under the panel, on + // a lower layer, with a Cancel of its own; `assert_fits` sees every + // layer at once, so the panel is checked on its own. + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(640.0, 420.0)); + let panel = h + .ctx + .memory(|m| m.area_rect(Id::new("Stream settings"))) + .expect("the panel is showing"); + assert_inside("Stream settings", panel, below_bar(&h, screen)); + for control in ["Apply and reconnect", "Close Stream settings"] { + assert_inside(control, node_rect(&h.get_by_label(control)), panel); + } + assert_cancel_inside(&h, panel); + h.get_by_label("Close Stream settings").click(); + h.run_steps(2); + assert!(h.state().view.settings.is_none()); + } + + fn node_rect(node: &egui_kittest::kittest::Node<'_>) -> Rect { + let b = node.raw_bounds().expect("node has bounds"); + Rect::from_min_max( + Pos2::new(b.x0 as f32, b.y0 as f32), + Pos2::new(b.x1 as f32, b.y1 as f32), + ) + } + + /// Press Tab until the control called `label` has keyboard focus. + pub(crate) fn tab_to(h: &mut egui_kittest::Harness<'_, S>, label: &str) { + for _ in 0..60 { + if h.query_by_label(label).is_some_and(|n| n.is_focused()) { + h.run_steps(2); + return; + } + h.press_key(egui::Key::Tab); + h.run_steps(1); + } + panic!("Tab never reached {label:?}"); + } + + /// `inner` lies inside `outer`, give or take half a point of rounding. + pub(crate) fn assert_inside(what: &str, inner: Rect, outer: Rect) { + assert!( + outer.expand(0.5).contains_rect(inner), + "{what} {inner:?} runs outside {outer:?}" + ); + } + + /// Open the menu behind `trigger` and check the whole menu is on + /// screen and that Tab brings its last item, `last`, into view. + pub(crate) fn assert_menu_fits( + h: &mut egui_kittest::Harness<'_, S>, + size: Vec2, + trigger: &str, + last: &str, + ) { + let screen = Rect::from_min_size(Pos2::ZERO, size); + h.get_by_label(trigger).simulate_click(); + h.run_steps(3); + let menu = brolink_ui::open_menu_rect(&h.ctx).expect("the menu is open"); + assert_inside(&format!("{trigger} menu at {size:?}"), menu, screen); + tab_to(h, last); + let menu = brolink_ui::open_menu_rect(&h.ctx).expect("Tab keeps the menu open"); + assert_inside(&format!("{trigger} menu after Tab"), menu, screen); + let item = node_rect(&h.get_by_label(last)); + assert_inside(&format!("{last:?} in the {trigger} menu"), item, menu); + } + + fn windows_old_host() -> Extra { + Extra { + os: "windows".into(), + old_host: Some((Version::new(3, 0, 0), Some(Version::new(4, 0, 2)))), + ..Default::default() + } + } + + /// The last item each toolbar menu can show: the power items end the + /// More menu, and with an old host the update item ends them. + const LAST_POWER_ITEM: &str = "Update BroLink Host… (runs 3.0.0)"; + + #[test] + fn every_toolbar_menu_fits_the_smallest_window_and_scrolls_to_its_end() { + let min = Vec2::new(640.0, 420.0); + let mut h = harness_with( + min, + 1.0, + false, + fixture(view_with_bar(), windows_old_host()), + ); + assert_menu_fits(&mut h, min, "More", LAST_POWER_ITEM); + // The separate menus exist from this width up; at the minimum + // height they must fit too. + let short = Vec2::new(OVERFLOW_BELOW, 420.0); + let last_key = if cfg!(target_os = "macos") { + "Command acts as Ctrl" + } else { + "Print Screen" + }; + for (trigger, last) in [ + ("Keys", last_key), + ("Mouse", "Free, for desktops (follows this cursor)"), + ("PC", LAST_POWER_ITEM), + ] { + let mut h = harness_with( + short, + 1.0, + false, + fixture(view_with_bar(), windows_old_host()), + ); + assert_menu_fits(&mut h, short, trigger, last); + } + } + + #[test] + fn the_stream_panels_and_questions_fit_the_smallest_window() { + let min = Vec2::new(640.0, 420.0); + let screen = Rect::from_min_size(Pos2::ZERO, min); + let area = |h: &egui_kittest::Harness<'_, Fixture>, id: &str| { + h.ctx + .memory(|m| m.area_rect(Id::new(id))) + .unwrap_or_else(|| panic!("{id} is showing")) + }; + + let mut view = view_with_bar(); + view.stats = true; + let mut h = harness_with(min, 1.0, false, fixture(view, windows_old_host())); + tab_to(&mut h, "Copy diagnostics"); + let below = below_bar(&h, screen); + assert_inside("Stream performance", area(&h, "Stream performance"), below); + let copy = node_rect(&h.get_by_label("Copy diagnostics")); + assert_inside("Copy diagnostics", copy, area(&h, "Stream performance")); + + let mut view = view_with_bar(); + view.settings = Some(ClientConfig::default().stream); + let mut h = harness_with(min, 1.0, false, fixture(view, windows_old_host())); + tab_to(&mut h, "H.264"); + let panel = area(&h, "Stream settings"); + assert_inside("Stream settings", panel, below_bar(&h, screen)); + for control in ["H.264", "Apply and reconnect"] { + assert_inside(control, node_rect(&h.get_by_label(control)), panel); + } + assert_cancel_inside(&h, panel); + + for (id, view, action) in [ + ( + "stream-confirm", + View { + confirm: Some(PowerAction::Shutdown), + ..view_with_bar() }, + PowerAction::Shutdown.label(), + ), + ( + "stream-install", + View { + confirm_install: true, + ..view_with_bar() + }, + "Install through the stream", + ), + ] { + let mut h = harness_with(min, 1.0, false, fixture(view, windows_old_host())); + tab_to(&mut h, action); + assert_inside(id, area(&h, id), below_bar(&h, screen)); + assert_inside(action, node_rect(&h.get_by_label(action)), area(&h, id)); + assert_cancel_inside(&h, area(&h, id)); + } + } + + /// The window below the toolbar: where panels and questions go. + fn below_bar(h: &egui_kittest::Harness<'_, Fixture>, screen: Rect) -> Rect { + Rect::from_min_max(Pos2::new(0.0, h.state().view.bar_h), screen.max) + } + + /// The Cancel that ends a panel or question lies inside it. (The + /// screen waiting for the first picture has a Cancel of its own.) + fn assert_cancel_inside(h: &egui_kittest::Harness<'_, Fixture>, area: Rect) { + assert!( + h.query_all_by_label("Cancel") + .any(|n| area.expand(0.5).contains_rect(node_rect(&n))), + "no Cancel inside {area:?}" + ); + } + + #[test] + #[ignore = "renders with a GPU; run on demand to review the UI"] + fn snapshots_stream_menus_at_the_smallest_size() { + use crate::app::snapshots::{file_name, save}; + let min = Vec2::new(640.0, 420.0); + let short = Vec2::new(OVERFLOW_BELOW, 420.0); + let last_key = if cfg!(target_os = "macos") { + "Command acts as Ctrl" + } else { + "Print Screen" + }; + for ppp in [1.0, 2.0] { + for (name, size, trigger, last) in [ + ("more-end", min, "More", LAST_POWER_ITEM), + ("keys-end", short, "Keys", last_key), + ( + "mouse-end", + short, + "Mouse", + "Free, for desktops (follows this cursor)", + ), + ("power-end", short, "PC", LAST_POWER_ITEM), + ] { + let mut h = harness_with( + size, + ppp, + true, + fixture(view_with_bar(), windows_old_host()), + ); + assert_menu_fits(&mut h, size, trigger, last); + save(h.render().unwrap(), &file_name("stream", name, size, ppp)); + } + for (name, view, last) in [ + ( + "stats-end", + View { + stats: true, + ..view_with_bar() + }, + "Copy diagnostics", + ), + ( + "settings-end", + View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }, + "H.264", + ), + ( + "confirm-shutdown", + View { + confirm: Some(PowerAction::Shutdown), + ..view_with_bar() + }, + PowerAction::Shutdown.label(), + ), + ( + "install-host", + View { + confirm_install: true, + ..view_with_bar() + }, + "Install through the stream", + ), + ] { + let mut h = harness_with(min, ppp, true, fixture(view, windows_old_host())); + tab_to(&mut h, last); + save(h.render().unwrap(), &file_name("stream", name, min, ppp)); + } + // A dropdown in the settings panel opens where it has room. + let view = View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }; + let mut h = harness_with(min, ppp, true, fixture(view, windows_old_host())); + // The frame rate select comes just before the bitrate slider. + use egui::accesskit::Role; + use egui_kittest::kittest::By; + let focused = |h: &egui_kittest::Harness<'_, Fixture>, role: Role| { + h.query_all(By::new().predicate(|n| n.is_focused())) + .any(|n| n.role() == role) + }; + for _ in 0..30 { + if focused(&h, Role::Slider) { + break; + } + h.press_key(egui::Key::Tab); + h.run_steps(1); + } + h.press_key_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(2); + assert!(focused(&h, Role::ComboBox), "Shift-Tab reaches the select"); + h.query_all(By::new().predicate(|n| n.is_focused())) + .next() + .unwrap() + .simulate_click(); + h.run_steps(3); + let screen = Rect::from_min_size(Pos2::ZERO, min); + let list = brolink_ui::open_menu_rect(&h.ctx).expect("the list is open"); + assert_inside("the frame rate list", list, screen); + tab_to(&mut h, "240 fps"); + let list = brolink_ui::open_menu_rect(&h.ctx).expect("Tab keeps the list open"); + assert_inside("240 fps", node_rect(&h.get_by_label("240 fps")), list); + save( + h.render().unwrap(), + &file_name("stream", "fps-open", min, ppp), ); - h.run_steps(3); - save_snapshot(h.render().unwrap(), "client-stream-toolbar-640.png"); + } } #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn stream_toolbar_snapshot_normal_width() { - let mut h = egui_kittest::Harness::builder() - .wgpu() - .with_size(Vec2::new(1400.0, 860.0)) - .with_pixels_per_point(1.0) - .with_max_steps(2) - .build_state( - paint, - Fixture { - view: view_with_bar(), - live: None, - cfg: ClientConfig::default(), - fullscreen: false, - applied: false, - }, + fn snapshots_stream_overlay() { + use crate::app::snapshots::{file_name, save, MATRIX, PAIR}; + let windows = || Extra { + os: "windows".into(), + ..Default::default() + }; + for &(size, ppp) in &MATRIX { + let mut h = harness_with(size, ppp, true, fixture(view_with_bar(), windows())); + save( + h.render().unwrap(), + &file_name("stream", "toolbar", size, ppp), ); - h.run_steps(3); - save_snapshot(h.render().unwrap(), "client-stream-toolbar.png"); + } + let shots: Vec<(&str, View, Extra, Option<&str>)> = vec![ + ("waiting", View::default(), windows(), None), + ( + "hint", + View { + hint_since: Some(Instant::now()), + ..View::default() + }, + windows(), + None, + ), + ("more-open", view_with_bar(), windows(), Some("More")), + ("keys-open", view_with_bar(), windows(), Some("Keys")), + ("mouse-open", view_with_bar(), windows(), Some("Mouse")), + ( + "power-open", + view_with_bar(), + Extra { + old_host: Some((Version::new(3, 0, 0), Some(Version::new(4, 0, 2)))), + ..windows() + }, + Some("PC"), + ), + ( + "confirm-restart", + View { + confirm: Some(PowerAction::Restart), + ..view_with_bar() + }, + windows(), + None, + ), + ( + "install-host", + View { + confirm_install: true, + ..view_with_bar() + }, + Extra { + old_host: Some((Version::new(3, 0, 0), Some(Version::new(4, 0, 2)))), + ..windows() + }, + None, + ), + ( + "settings", + View { + settings: Some(ClientConfig::default().stream), + ..View::default() + }, + windows(), + None, + ), + ( + "stats", + View { + stats: true, + ..view_with_bar() + }, + windows(), + None, + ), + ( + "black-picture", + View { + problem: Some("The PC is sending a black picture: the connection and the video are healthy, but every frame is blank. Asking the PC why…".into()), + ..View::default() + }, + Extra { + video_help: Some(crate::display::Help { + message: "The PC's desktop is composed in HDR. The capture converts it to an ordinary picture, and on this PC that conversion comes out black. Turning HDR off fixes it.".into(), + hdr_is_on: true, + mode: "HDR".into(), + busy: false, + }), + ..windows() + }, + None, + ), + ( + "toasts", + { + let mut v = View::default(); + v.toast(Tone::Warning, "Gaming-PC has no sound to send: it reports “Unable to initialize audio capture”. Its Sharing page in BroLink says more."); + v.set_poor(true); + v + }, + Extra { + handover: Some("Installing BroLink Host 4.0.2 on Gaming-PC: waiting for it to fetch the file…".into()), + ..windows() + }, + None, + ), + ]; + for (name, view, extra, open) in shots { + let sizes: &[(Vec2, f32)] = if name == "more-open" { + &[ + (Vec2::new(640.0, 420.0), 2.0), + (Vec2::new(960.0, 640.0), 2.0), + ] + } else if name == "settings" { + &PAIR + } else { + &[(Vec2::new(1280.0, 800.0), 2.0)] + }; + for &(size, ppp) in sizes { + let view = View { + settings: view.settings.clone(), + stats: view.stats, + bar_shown: view.bar_shown, + confirm: view.confirm, + confirm_install: view.confirm_install, + hint_since: view.hint_since, + toasts: view + .toasts + .iter() + .map(|t| Toast { + tone: t.tone, + text: t.text.clone(), + at: t.at, + }) + .collect(), + poor: view.poor, + poor_hinted: view.poor_hinted, + problem: view.problem.clone(), + ..View::default() + }; + let mut h = harness_with(size, ppp, true, fixture(view, extra.clone())); + if let Some(label) = open { + h.get_by_label(label).simulate_click(); + h.run_steps(3); + } + save(h.render().unwrap(), &file_name("stream", name, size, ppp)); + } + } } } diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 0cbf6cd..02ef47e 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -473,7 +473,7 @@ fn should_push(running: &Version, rel: &Release) -> bool { pub fn old_host_message(name: &str, version: &Version) -> String { format!( - "{name} runs BroLink Host {version}, which cannot take an update over the network. Connect to it and choose PC → Update BroLink Host in the toolbar: this Mac installs the new version through the stream. After that, updates are automatic." + "{name} runs BroLink Host {version}, which cannot take an update over the network. Connect to it and choose PC → Update BroLink Host in the toolbar: this machine installs the new version through the stream. After that, updates are automatic." ) } diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index 21820de..4c5366a 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -1,4 +1,7 @@ -//! The host control panel: a status window with one setup button. +//! The Sharing page: setup for sharing this machine, what it reports, the +//! devices paired with it, its options and diagnostics. The unified window +//! shows it as a tab (see `product.rs`); background threads keep +//! [`Shared`] current and the page reads it once per frame. use crate::config::{self, HostConfig}; use crate::setup; @@ -6,7 +9,7 @@ use crate::streamer::Api; use brolink_core::api::Status; use brolink_core::http; use brolink_core::CONTROL_PORT; -use brolink_ui::{self as ui, Tone, PALETTE as P}; +use brolink_ui::{self as ui, space, Kv, Tone}; use eframe::egui; use parking_lot::Mutex; use semver::Version; @@ -14,7 +17,8 @@ use std::cmp::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; -const COLUMN_WIDTH: f32 = 680.0; +/// How long the result of an action (an unpair, a driver install) stays. +const NOTE_FOR: Duration = Duration::from_secs(10); /// What the background threads know, read by the UI every frame. #[derive(Default)] @@ -26,6 +30,53 @@ pub struct Shared { pub setup_running: bool, pub setup_result: Option>, pub setup_log: Vec, + /// Moves whenever anything above changes, so a reader can skip copying + /// what it already has. + pub rev: u64, +} + +impl Shared { + fn touch(&mut self) { + self.rev = self.rev.wrapping_add(1); + } +} + +/// A one-line result from a worker thread. +type Note = Arc>>; + +/// Which OS the page describes. Compiled in for the running app; set by +/// tests to render the other platforms' pages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Os { + Windows, + Mac, + Linux, +} + +impl Os { + pub const HERE: Os = if cfg!(windows) { + Os::Windows + } else if cfg!(target_os = "macos") { + Os::Mac + } else { + Os::Linux + }; + + fn noun(self) -> &'static str { + match self { + Os::Windows => "PC", + Os::Mac => "Mac", + Os::Linux => "machine", + } + } + + fn tailscale_url(self) -> &'static str { + match self { + Os::Windows => "https://tailscale.com/download/windows", + Os::Mac => "https://tailscale.com/download/mac", + Os::Linux => "https://tailscale.com/download/linux", + } + } } pub struct HostApp { @@ -33,121 +84,132 @@ pub struct HostApp { cfg: HostConfig, autostart: bool, dirty: bool, - brand: ui::Brand, confirm_unpair: Option, - notices_open: bool, - settings_open: bool, busy_since: Option, /// The engine archive sits beside the exe, so setup needs no download. bundled_engine: bool, + /// Actions reach the system: setup runs, devices unpair, the service + /// stops. Off when the page is only rendered, in tests. + live: bool, + os: Os, + note: Option<(Tone, String, Instant)>, + pending: Option, } impl HostApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let shared = Arc::new(Mutex::new(Shared::default())); spawn_poller(shared.clone(), cc.egui_ctx.clone()); - Self::with_shared(cc, shared) + Self { + cfg: HostConfig::load(), + autostart: setup::starts_with_windows(), + bundled_engine: std::env::current_exe().is_ok_and(|e| setup::bundled_engine(&e)), + live: true, + ..Self::headless(shared, Os::HERE) + } } - fn with_shared(cc: &eframe::CreationContext<'_>, shared: Arc>) -> Self { - ui::apply(&cc.egui_ctx); + /// A page that only draws: default settings, no threads, and buttons + /// that change what it shows but never touch the system. + #[doc(hidden)] + pub fn headless(shared: Arc>, os: Os) -> Self { Self { shared, - cfg: HostConfig::load(), - autostart: setup::starts_with_windows(), + cfg: HostConfig::default(), + autostart: true, dirty: false, - brand: ui::Brand::new(&cc.egui_ctx), confirm_unpair: None, - notices_open: false, - settings_open: false, busy_since: None, - bundled_engine: std::env::current_exe().is_ok_and(|e| setup::bundled_engine(&e)), + bundled_engine: os == Os::Windows, + live: false, + os, + note: None, + pending: None, } } fn commit(&mut self) { if self.dirty { self.dirty = false; + if !self.live { + return; + } if let Err(e) = self.cfg.save() { tracing::warn!("could not save host config: {e:#}"); + self.note = Some(( + Tone::Danger, + format!("Couldn't save this setting: {e}."), + Instant::now(), + )); } } } - fn api(&self) -> Option> { - self.cfg.has_creds().then_some(Api { - user: &self.cfg.sunshine_user, - pass: &self.cfg.sunshine_pass, - }) - } - pub(crate) fn shared(&self) -> parking_lot::MutexGuard<'_, Shared> { self.shared.lock() } - pub(crate) fn request_setup(&mut self) { - let status = self.shared.lock().status.clone(); - if let Some(s) = status { - self.start_setup(&s); - } else { - crate::service::ensure_service_running(); - self.shared.lock().setup_result = Some(Err( - "The sharing service is starting. Wait for its status, then try setup again." - .into(), - )); - } - } - - pub(crate) fn apply_share_toggles( + /// Run `job` off the UI thread and show what it returns under the + /// page's header. + fn later( &mut self, - power: Option, - stay_awake: Option, - autostart: Option, + ctx: &egui::Context, + job: impl FnOnce() -> (Tone, String) + Send + 'static, ) { - if let Some(v) = power { - self.cfg.power_allowed = v; - self.dirty = true; - } - if let Some(v) = stay_awake { - self.cfg.stay_awake = v; - self.dirty = true; + let note: Note = Arc::default(); + let out = note.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + *out.lock() = Some(job()); + ctx.request_repaint(); + }); + self.pending = Some(note); + } + + fn poll_note(&mut self, ctx: &egui::Context) { + if let Some(done) = self.pending.as_ref().and_then(|n| n.lock().take()) { + self.note = Some((done.0, done.1, Instant::now())); + self.pending = None; } - if let Some(v) = autostart { - if let Ok(exe) = std::env::current_exe() { - if crate::setup::set_start_with_windows(v, &exe).is_ok() { - self.autostart = v; - self.cfg.start_with_windows = v; - self.dirty = true; - } + if let Some((_, _, at)) = &self.note { + match NOTE_FOR.checked_sub(at.elapsed()) { + Some(left) => ctx.request_repaint_after(left), + None => self.note = None, } } - self.commit(); } - pub(crate) fn publish_share(&self, local: &brolink_client::share::Slot) { - let mut g = local.lock(); - g.power_allowed = self.cfg.power_allowed; - g.stay_awake = self.cfg.stay_awake; - g.autostart = self.autostart; + /// The engine's login, for a worker thread. + fn creds(&self) -> Option<(String, String)> { + self.cfg.has_creds().then(|| { + ( + self.cfg.sunshine_user.clone(), + self.cfg.sunshine_pass.clone(), + ) + }) } /// Generate a Sunshine login if there is none, save it, and run the - /// elevated script on a thread. + /// setup on a thread. fn start_setup(&mut self, status: &Status) { let migrate = crate::migrate::uses_old_engine(&status.streamer.kind); - if !self.cfg.has_creds() && !migrate { - self.cfg.sunshine_user = "brolink".into(); - self.cfg.sunshine_pass = config::random_password(); - if let Err(e) = self.cfg.save() { - tracing::warn!("could not save engine login: {e:#}"); - } - } let shared = self.shared.clone(); { let mut s = shared.lock(); s.setup_running = true; s.setup_result = None; s.setup_log.clear(); + s.touch(); + } + if !self.live { + return; + } + if !self.cfg.has_creds() && !migrate { + self.cfg.sunshine_user = "brolink".into(); + self.cfg.sunshine_pass = config::random_password(); + if let Err(e) = self.cfg.save() { + tracing::warn!("could not save engine login: {e:#}"); + } } let cfg = self.cfg.clone(); let install_engine = !status.streamer.installed || migrate || !status.streamer.running; @@ -168,8 +230,8 @@ impl HostApp { adapter_description: &desc, }) .map_err(|e| e.to_string())?; - // The service is what the Mac needs at logon; turn it on - // once setup has succeeded rather than asking. + // The service is what other machines need at logon; turn + // it on once setup has succeeded rather than asking. let _ = setup::set_start_with_windows(true, &exe); Ok(()) }); @@ -177,13 +239,95 @@ impl HostApp { s.setup_running = false; s.setup_log = read_setup_log(); s.setup_result = Some(result); + s.touch(); }); } + + fn set_autostart(&mut self, on: bool) { + if !self.live { + self.autostart = on; + return; + } + let result = std::env::current_exe() + .map_err(anyhow::Error::from) + .and_then(|exe| setup::set_start_with_windows(on, &exe)); + match result { + Ok(()) => { + self.autostart = on; + self.cfg.start_with_windows = on; + self.dirty = true; + } + Err(e) => { + tracing::warn!("autostart: {e:#}"); + self.note = Some(( + Tone::Danger, + format!("Couldn't change whether BroLink starts at login: {e}."), + Instant::now(), + )); + } + } + } + + fn stop_service(&mut self) { + if self.live { + // Off the UI thread: the service may take a moment to answer. + std::thread::spawn(|| { + let _ = http::request( + ("127.0.0.1", CONTROL_PORT), + "POST", + "/v1/quit", + None, + Duration::from_secs(2), + ); + }); + } + let mut s = self.shared.lock(); + s.status = None; + s.service_error = Some(STOPPED.into()); + s.touch(); + } + + fn start_service(&mut self) { + { + let mut s = self.shared.lock(); + s.service_error = None; + s.touch(); + } + if self.live { + std::thread::spawn(crate::service::ensure_service_running); + } + } + + fn open_log_folder(&mut self) { + if !self.live { + return; + } + let Ok(dir) = brolink_core::config::data_dir() else { + return; + }; + let opener = match self.os { + Os::Windows => "explorer", + Os::Mac => "open", + Os::Linux => "xdg-open", + }; + if let Err(e) = std::process::Command::new(opener).arg(&dir).spawn() { + self.note = Some(( + Tone::Danger, + format!("Couldn't open {}: {e}.", dir.display()), + Instant::now(), + )); + } + } } -impl eframe::App for HostApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - ctx.request_repaint_after(Duration::from_millis(500)); +/// What the service error says after the user stopped it from here. +const STOPPED: &str = "Stopped from this window."; + +impl HostApp { + /// Draw the page into `ui`, a column the window has already laid out. + pub fn page(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.poll_note(&ctx); let shared = self.shared.lock(); let status = shared.status.clone(); let service_error = shared.service_error.clone(); @@ -195,101 +339,90 @@ impl eframe::App for HostApp { drop(shared); if setup_running { self.busy_since.get_or_insert_with(Instant::now); + // The seconds counter under the spinner. + ctx.request_repaint_after(Duration::from_secs(1)); } else { self.busy_since = None; } - ui::top_bar(ctx, "top", |ui| { - let (label, tone) = pill(status.as_ref(), service_error.as_deref()); - self.brand.header(ui, "BroLink Host", |ui| { - if ui::ghost_button( - ui, - if self.settings_open { - "Overview" - } else { - "Settings" - }, - ) - .clicked() - { - self.settings_open = !self.settings_open; - } - ui::status_pill(ui, label, tone); - }); + let Some(s) = status else { + self.no_service(ui, service_error.as_deref()); + return; + }; + let (label, tone) = pill(Some(&s), None); + ui::page_header(ui, &s.name, Some(&self.summary(&s)), |ui| { + ui::status_text(ui, tone, label); }); + if let Some((tone, text, _)) = self.note.clone() { + ui::notice(ui, tone, &text); + } + if s.version != env!("CARGO_PKG_VERSION") { + ui::notice( + ui, + Tone::Neutral, + &format!( + "The background service runs BroLink {} and this window {}; BroLink is restarting the older one.", + s.version, + env!("CARGO_PKG_VERSION") + ), + ); + } + self.setup_card(ui, &s, setup_running, setup_result.as_ref(), &setup_log); + self.overview(ui, &s, gamepad); + self.paired(ui, &clients); + self.options(ui); + self.diagnostics(ui, &s, setup_running); + self.commit(); + } - ui::bottom_bar(ctx, "bottom", |ui| { - ui.horizontal(|ui| { - ui.label(format!("v{}", env!("CARGO_PKG_VERSION"))); - ui.label("·"); - ui.label(format!("control port TCP {CONTROL_PORT}")); - if let Some(s) = &status { - if s.version != env!("CARGO_PKG_VERSION") { - ui.label("·"); - ui.colored_label( - P.accent, - format!("service is v{}, restarting it", s.version), - ); + /// One sentence under the machine's name: can others connect? + fn summary(&self, s: &Status) -> String { + let noun = self.os.noun(); + match pill(Some(s), None).0 { + "Tailscale off" => format!( + "Tailscale isn't running on this {noun}, so no other machine can reach it." + ), + "Needs setup" => format!( + "Other machines can see this {noun} but can't connect until setup finishes." + ), + _ => format!( + "Shared. Any machine signed in to your Tailscale account can connect to this {noun}." + ), + } + } + + fn no_service(&mut self, ui: &mut egui::Ui, error: Option<&str>) { + ui::page_header(ui, "Sharing", None, |_| {}); + if error == Some(STOPPED) { + ui::banner( + ui, + Tone::Neutral, + "The background service is stopped", + Some(&format!( + "Other machines can't connect to this {} until it runs again. It also starts the next time this window opens.", + self.os.noun() + )), + |ui| { + if ui::primary_button(ui, "Start it now").clicked() { + self.start_service(); } + }, + ); + } else { + ui::card(ui, |ui| { + ui::heading(ui, "Starting the background service", None); + ui::empty_state( + ui, + "It shares this machine and answers the others. This takes a second or two.", + true, + ); + if let Some(e) = error { + ui::small_print(ui, format!("Last attempt: {e}")); } }); - }); - - egui::CentralPanel::default() - .frame(egui::Frame::new().fill(P.bg)) - .show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add_space(20.0); - ui::content_column(ui, COLUMN_WIDTH, |ui| { - ui.spacing_mut().item_spacing.y = 14.0; - match &status { - Some(s) => { - self.setup_card( - ui, - s, - setup_running, - setup_result.as_ref(), - &setup_log, - ); - if self.settings_open { - self.settings_card(ui); - ui::titled_card(ui, "Repair streaming", Some("Reapply display matching and remove stale bitrate limits. Setup preserves paired Macs."), |ui| { - if ui.add_enabled(!setup_running, egui::Button::new("Repair setup")).clicked() { self.start_setup(s); } - }); - } else { - ui.label(egui::RichText::new(&s.name).font(ui::theme::semibold(28.0)).color(P.text)); - ui::caption(ui, "Your desktop is available to every BroLink on this Tailscale account."); - self.pc_card(ui, s, gamepad); - self.paired_card(ui, &clients); - } - ui::collapsible(ui, "host-diagnostics", "Diagnostics", false, |ui| { - ui::log_view(ui, "host_log", &s.log, 200.0); - if ui::ghost_button(ui, "Copy log").clicked() { ui.ctx().copy_text(s.log.join("\n")); } - }); - } - None => { - ui::toned_card(ui, Tone::Neutral, |ui| { - ui::heading(ui, "Starting the background service", None); - ui::empty_state( - ui, - service_error - .as_deref() - .unwrap_or("This takes a second or two."), - true, - ); - }); - } - } - ui.add_space(10.0); - }); - }); - }); - - self.commit(); + } } -} -impl HostApp { fn setup_card( &mut self, ui: &mut egui::Ui, @@ -305,307 +438,485 @@ impl HostApp { .filter(|l| !l.starts_with("Tailscale")) .collect(); let tailscale_item = s.setup.iter().find(|l| l.starts_with("Tailscale")); - if admin_items.is_empty() && tailscale_item.is_none() && result.is_none() && !running { + let done = admin_items.is_empty() && tailscale_item.is_none(); + if done && !running && !matches!(result, Some(Err(_))) { + if result.is_some() { + ui::notice( + ui, + Tone::Success, + &format!( + "Setup finished. Other machines can connect to this {} now.", + self.os.noun() + ), + ); + } return; } - let tone = if admin_items.is_empty() && tailscale_item.is_none() { + let migrate = crate::migrate::uses_old_engine(&s.streamer.kind); + let tone = if done && !matches!(result, Some(Err(_))) { Tone::Success + } else if matches!(result, Some(Err(_))) { + Tone::Danger } else { - Tone::Accent + Tone::Warning }; + let (title, how) = match self.os { + Os::Windows => ( + if migrate { + "Update the streaming engine" + } else { + "Share this PC" + }, + "One administrator prompt does everything listed here. Nothing else needs configuring.", + ), + Os::Mac => ( + "Share this Mac", + "BroLink installs the streaming engine for you. macOS then asks once to let it record the screen.", + ), + Os::Linux => ( + "Share this machine", + "BroLink installs the streaming engine for this user and keeps it running.", + ), + }; + let title = if done { "Sharing is set up" } else { title }; ui::toned_card(ui, tone, |ui| { - ui::heading( - ui, - "Set up this PC", - Some("One administrator prompt does everything below. Nothing else needs configuring."), - ); + ui.spacing_mut().item_spacing.y = space::SM; + ui::heading(ui, title, (!done).then_some(how)); if let Some(t) = tailscale_item { - ui::notice(ui, Tone::Danger, t); - ui.horizontal(|ui| { - if ui::ghost_button(ui, "Get Tailscale").clicked() { - ui.ctx().open_url(egui::OpenUrl::new_tab( - "https://tailscale.com/download/windows", - )); + let why = t + .trim_start_matches("Tailscale:") + .trim() + .trim_end_matches("Install it and sign in.") + .trim() + .trim_end_matches('.'); + // The link belongs to the item: it starts where its text does. + ui::dot_item(ui, Tone::Danger, |ui| { + ui.spacing_mut().item_spacing.y = space::XXS; + ui.add( + egui::Label::new(egui::RichText::new(format!( + "{}. Install Tailscale and sign in with the account your other machines use.", + sentence_case(why) + )).color(ui::PALETTE.text)) + .wrap(), + ); + if ui::link(ui, "Get Tailscale").clicked() { + ui.ctx() + .open_url(egui::OpenUrl::new_tab(self.os.tailscale_url())); } - ui::caption(ui, "Sign in with the same account as your Mac."); }); } for item in &admin_items { - ui::dot_label(ui, Tone::Accent, item); - } - if admin_items.is_empty() && tailscale_item.is_none() { - ui::dot_label(ui, Tone::Success, "Everything is in place."); + ui::dot_label(ui, Tone::Warning, item); } - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - if running { - ui::empty_state( - ui, - &format!( - "Waiting for the administrator prompt and the setup steps… {}s", - self.busy_since.map(|t| t.elapsed().as_secs()).unwrap_or(0) - ), - true, - ); - } else { - let migrate = crate::migrate::uses_old_engine(&s.streamer.kind); + ui.add_space(space::XS); + if running { + let secs = self.busy_since.map(|t| t.elapsed().as_secs()).unwrap_or(0); + let what = match self.os { + Os::Windows => "Waiting for the administrator prompt and the setup steps", + _ => "Installing and starting the streaming engine", + }; + ui::empty_state(ui, &format!("{what}… {secs} s"), true); + } else if !done || result.is_some() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = space::MD; let label = if migrate { - "Update this PC" + "Update the engine" } else if admin_items.is_empty() { "Run setup again" + } else if self.os == Os::Windows { + "Set up as administrator" } else { - "Set up this PC (administrator)" + "Set up sharing" }; - if ui::primary_button(ui, label).clicked() { + let primary = !admin_items.is_empty() || migrate; + let clicked = if primary { + ui::primary_button(ui, label).clicked() + } else { + ui::secondary_button(ui, label).clicked() + }; + if clicked { self.start_setup(s); } - if migrate { - ui::caption( - ui, - "One administrator prompt. Keeps this PC's pairing and web login.", - ); + let note = if migrate { + Some("Keeps this PC's pairings and web login.") } else if !s.streamer.installed { - ui::caption( - ui, - if self.bundled_engine { - "Installs the streaming engine that ships with BroLink." - } else { - "Downloads the streaming engine and installs it." - }, - ); + Some(match (self.os, self.bundled_engine) { + (Os::Windows, true) => "Installs the engine that ships with BroLink.", + _ => "Downloads the streaming engine and installs it.", + }) + } else { + None + }; + if let Some(n) = note { + ui::small_print(ui, n); } - } - }); + }); + } match result { Some(Ok(())) => { ui::notice( ui, Tone::Success, - "Setup finished. The service re-checks everything within a few seconds.", + "Setup finished. The service is re-checking; the items above clear within a few seconds.", ); } Some(Err(e)) => { - ui::notice(ui, Tone::Danger, e); + let friendly = setup_error(e, self.os); + ui::banner( + ui, + Tone::Danger, + "Setup didn't finish", + Some(&friendly), + |ui| { + if !friendly.contains(e.trim_end_matches('.')) { + ui.add( + egui::Label::new( + egui::RichText::new(e) + .font(ui::theme::mono(ui::theme::text::MONO - 1.0)) + .color(ui::PALETTE.text_tertiary), + ) + .wrap(), + ); + } + }, + ); } None => {} } if !log.is_empty() { - ui::collapsible( + ui::disclosure( ui, "setup_log", "Setup log", - result.as_ref().is_some_and(|r| r.is_err()), + matches!(result, Some(Err(_))), |ui| { - ui::log_view(ui, "setup_log_view", log, 160.0); + ui::log_view(ui, "setup_log_view", log, 180.0); }, ); } }); } - fn pc_card(&mut self, ui: &mut egui::Ui, s: &Status, gamepad: Option) { - ui::titled_card(ui, "This PC", None, |ui| { - let tailscale = match (&s.tailscale_ip, &s.tailscale_login) { - (Some(ip), Some(login)) => format!("{ip} · signed in as {login}"), - (Some(ip), None) => ip.clone(), - _ => "not running".into(), + fn overview(&mut self, ui: &mut egui::Ui, s: &Status, gamepad: Option) { + let noun = self.os.noun(); + let (ts_tone, tailscale) = match (&s.tailscale_ip, &s.tailscale_login) { + (Some(ip), Some(login)) => (Tone::Success, format!("{ip} · {login}")), + (Some(ip), None) => (Tone::Success, ip.clone()), + _ => (Tone::Danger, "Not running".into()), + }; + let (engine_tone, engine) = if !s.streamer.installed { + (Tone::Warning, "Not installed".to_string()) + } else if !s.streamer.running { + (Tone::Warning, "Installed, not running".to_string()) + } else if !s.streamer.api_ok { + ( + Tone::Warning, + "Running, but BroLink can't sign in to it".to_string(), + ) + } else { + let encoder = match s.streamer.encoder.as_str() { + "" => String::new(), + "software" => { + " · Software encoding: no GPU encoder worked, so streams use the CPU".into() + } + "nvenc" => " · NVIDIA encoder".into(), + "amdvce" => " · AMD encoder".into(), + "quicksync" => " · Intel encoder".into(), + "videotoolbox" => " · Apple encoder".into(), + e => format!(" · {e} encoder"), }; - let streamer = if !s.streamer.installed { - "not installed".to_string() + let tone = if s.streamer.encoder == "software" { + Tone::Warning } else { - format!( - "{} · {}{}{}{}", - s.streamer.kind, - if s.streamer.running { - "running" - } else { - "not running" - }, - if s.streamer.api_ok { - " · BroLink logged in" - } else { - "" - }, - match s.streamer.encoder.as_str() { - "" => String::new(), - "software" => - " · software encoder (no GPU encoder worked: streams will be slow)" - .into(), - e => format!(" · {e} encoder"), - }, - if s.streamer.audio_problem.is_empty() { - String::new() - } else { - format!(" · no sound: {}", s.streamer.audio_problem) - } - ) + Tone::Success }; - let network = match &s.nat { - Some(n) => crate::service::describe_nat(n) - .trim_start_matches("network: ") - .to_string(), - None => "checking…".into(), - }; - let mut wake = match (&s.mac, s.wake_ready) { - (Some(mac), Some(true)) => format!("ready · {} · {mac}", s.wake_adapter), - (Some(mac), Some(false)) => format!("off · {} · {mac}", s.wake_adapter), - (Some(mac), None) => format!("unknown · {} · {mac}", s.wake_adapter), - (None, _) => "no wired adapter found".into(), + (tone, format!("Running{encoder}")) + }; + let network = match &s.nat { + Some(n) => network_sentence(n, noun), + None => "Checking…".into(), + }; + let mut rows = vec![ + Kv::new("Tailscale", tailscale).tone(ts_tone), + Kv::new("Streaming engine", engine).tone(engine_tone), + ]; + if !s.streamer.audio_problem.is_empty() { + rows.push( + Kv::new( + "Sound", + format!( + "None to send: “{}”. Give this {noun} an audio output, even a virtual one.", + s.streamer.audio_problem + ), + ) + .tone(Tone::Warning), + ); + } + rows.push(Kv::new("Network", network)); + if self.os == Os::Windows { + let (tone, mut wake) = match (&s.mac, s.wake_ready) { + (Some(mac), Some(true)) => { + (Tone::Success, format!("Ready · {} · {mac}", s.wake_adapter)) + } + (Some(mac), Some(false)) => { + (Tone::Warning, format!("Off · {} · {mac}", s.wake_adapter)) + } + (Some(mac), None) => ( + Tone::Neutral, + format!("Unknown · {} · {mac}", s.wake_adapter), + ), + (None, _) => (Tone::Neutral, "No wired adapter found".into()), }; if s.fast_startup == Some(true) { - wake.push_str(" · Fast Startup on"); + wake.push_str(" · Fast Startup is on"); } if let Some(age) = s.wake_packet_age_secs { - wake.push_str(&format!(" · packet received {age}s ago")); + wake.push_str(&format!(" · Last wake packet {age} s ago")); } - let gamepad_text = match gamepad { - Some(true) => "virtual controller driver installed", - Some(false) => "virtual controller driver missing", - None => "unknown until the streaming engine is running", + rows.push(Kv::new("Wake-on-LAN", wake).tone(tone)); + } + rows.push(Kv::new( + "LAN address", + s.lan_ip.clone().unwrap_or_else(|| "None".into()), + )); + if self.os == Os::Windows { + let (tone, text) = match gamepad { + Some(true) => (Tone::Success, "Virtual controller driver installed"), + Some(false) => (Tone::Warning, "Virtual controller driver missing"), + None => (Tone::Neutral, "Known once the streaming engine runs"), }; - ui::kv_grid( - ui, - &[ - ("Name", s.name.clone()), - ("Tailscale", tailscale), - ("Network", network), - ("Streaming", streamer), - ("Wake-on-LAN", wake), + rows.push(Kv::new("Game controllers", text).tone(tone)); + } + let heading = format!("This {noun}"); + ui::section(ui, &heading, |ui| { + ui.add_space(space::MD); + ui::kv_grid(ui, &rows); + if self.os == Os::Windows && gamepad == Some(false) { + ui.add_space(space::SM); + if ui::secondary_button(ui, "Install controller driver").clicked() { + self.install_gamepad_driver(ui.ctx()); + } + } + ui.add_space(space::MD); + }); + } + + fn install_gamepad_driver(&mut self, ctx: &egui::Context) { + let Some((user, pass)) = self.creds().filter(|_| self.live) else { + return; + }; + self.later(ctx, move || { + let api = Api { + user: &user, + pass: &pass, + }; + match api.install_gamepad_driver() { + Ok(()) => ( + Tone::Success, + "The controller driver is installing. Windows may ask to confirm.".into(), + ), + Err(e) => { + tracing::warn!("ViGEmBus install: {e:#}"); ( - "LAN address", - s.lan_ip.clone().unwrap_or_else(|| "—".into()), - ), - ("Gamepads", gamepad_text.into()), - ], - ); - ui.add_space(4.0); - ui.horizontal(|ui| { - if gamepad == Some(false) - && ui::ghost_button(ui, "Install controller driver").clicked() - { - if let Some(api) = self.api() { - if let Err(e) = api.install_gamepad_driver() { - tracing::warn!("ViGEmBus install: {e:#}"); - } - } + Tone::Danger, + format!("Couldn't install the controller driver: {e}."), + ) } - }); + } }); } - fn paired_card(&mut self, ui: &mut egui::Ui, clients: &[(String, String)]) { - ui::titled_card( - ui, - "Paired Macs", - Some("A Mac pairs itself the first time it connects; nothing to type here."), - |ui| { - if clients.is_empty() { - ui::empty_state(ui, "No Mac has paired yet.", false); + fn paired(&mut self, ui: &mut egui::Ui, clients: &[(String, String)]) { + ui::section(ui, "Paired devices", |ui| { + if clients.is_empty() { + ui.add_space(space::MD); + ui::muted(ui, "None yet. A machine pairs itself the first time it connects; nothing needs typing here."); + ui.add_space(space::MD); + return; + } + for (i, (name, uuid)) in clients.iter().enumerate() { + if i > 0 { + ui::row_separator(ui); } - for (i, (name, uuid)) in clients.iter().enumerate() { - if i > 0 { - ui::row_separator(ui); - } - ui::list_row(ui, name, &uuid[..uuid.len().min(8)], |ui| { - if self.confirm_unpair.as_deref() == Some(uuid) { - if ui::toned_button(ui, "Unpair", Tone::Danger).clicked() { - if let Some(api) = self.api() { - if let Err(e) = api.unpair(uuid) { - tracing::warn!("unpair: {e:#}"); - } - } - self.confirm_unpair = None; - } - if ui::ghost_button(ui, "Keep").clicked() { - self.confirm_unpair = None; - } - } else if ui::danger_button(ui, "Unpair…").clicked() { - self.confirm_unpair = Some(uuid.clone()); + let id = format!("ID {}", &uuid[..uuid.len().min(8)]); + ui::list_row(ui, None, name, &id, |ui| { + if self.confirm_unpair.as_deref() == Some(uuid) { + if ui::ghost_button(ui, "Keep").clicked() { + self.confirm_unpair = None; } - }); + if ui::destructive_button(ui, "Unpair").clicked() { + self.unpair(ui.ctx(), name, uuid); + self.confirm_unpair = None; + } + } else if ui::danger_button(ui, "Unpair…") + .on_hover_text("It pairs again by itself the next time it connects.") + .clicked() + { + self.confirm_unpair = Some(uuid.clone()); + } + }); + } + }); + } + + fn unpair(&mut self, ctx: &egui::Context, name: &str, uuid: &str) { + let Some((user, pass)) = self.creds().filter(|_| self.live) else { + return; + }; + let (name, uuid) = (name.to_string(), uuid.to_string()); + self.later(ctx, move || { + let api = Api { + user: &user, + pass: &pass, + }; + match api.unpair(&uuid) { + Ok(()) => ( + Tone::Success, + format!("Unpaired {name}. It pairs again the next time it connects."), + ), + Err(e) => { + tracing::warn!("unpair: {e:#}"); + (Tone::Danger, format!("Couldn't unpair {name}: {e}.")) } - }, - ); + } + }); } - fn settings_card(&mut self, ui: &mut egui::Ui) { - ui::titled_card(ui, "Settings", None, |ui| { + fn options(&mut self, ui: &mut egui::Ui) { + let noun = self.os.noun(); + ui::section(ui, "Options", |ui| { if ui::toggle_row( ui, &mut self.cfg.power_allowed, - "Let a paired Mac sleep, restart, or shut down this PC", - Some("Devices allowed by your Tailscale access rules can ask. Waking a sleeping PC requires access to its local network."), + &format!("Let other machines sleep, restart or shut down this {noun}"), + Some(&if self.os == Os::Windows { + format!("Any machine your Tailscale access rules allow can ask. Waking this {noun} works only from its own network.") + } else { + "Any machine your Tailscale access rules allow can ask.".to_string() + }), ) { self.dirty = true; } + if self.os == Os::Windows { + ui::row_separator(ui); + if ui::toggle_row( + ui, + &mut self.cfg.stay_awake, + "Keep this PC awake while plugged in", + Some("Asleep, Tailscale is off too, so machines on other networks can't wake it. Sleep from the Start menu or another machine still works."), + ) { + self.dirty = true; + } + } ui::row_separator(ui); let mut auto = self.autostart; if ui::toggle_row( ui, - &mut self.cfg.stay_awake, - "Keep this PC awake while plugged in", - Some("Tailscale only works while the PC is on. Asleep, a Mac on another network cannot wake it. Sleep from the Mac or the Start menu still works."), + &mut auto, + "Start BroLink when you log in", + Some(&format!( + "So others can connect after this {noun} restarts, with nobody at the keyboard." + )), ) { - self.dirty = true; + self.set_autostart(auto); } + }); + } + + fn diagnostics(&mut self, ui: &mut egui::Ui, s: &Status, setup_running: bool) { + ui::section(ui, "Diagnostics", |ui| { + ui.add_space(space::XS); + ui::disclosure(ui, "host-diagnostics", "Service log", false, |ui| { + ui::log_view(ui, "host_log", &s.log, 220.0); + ui.add_space(space::XS); + if ui::secondary_button(ui, "Copy log").clicked() { + ui.ctx().copy_text(s.log.join("\n")); + } + }); ui::row_separator(ui); - if ui::toggle_row( + ui::setting_row( ui, - &mut auto, - "Start the background service with Windows", - Some("On by default, so the Mac can reach this PC after every restart without anyone at the keyboard."), - ) { - if let Ok(exe) = std::env::current_exe() { - match setup::set_start_with_windows(auto, &exe) { - Ok(()) => { - self.autostart = auto; - self.cfg.start_with_windows = auto; - self.dirty = true; - } - Err(e) => tracing::warn!("autostart: {e:#}"), + "Repair sharing", + Some("Runs setup again: reapplies display matching and clears stale bitrate limits. Paired devices stay paired."), + |ui| { + let clicked = ui + .add_enabled_ui(!setup_running, |ui| { + ui::secondary_button(ui, "Run setup again") + }) + .inner + .clicked(); + if clicked { + self.start_setup(s); } - } - } + }, + ); ui::row_separator(ui); ui::setting_row( ui, - "Updates", - Some("New versions of BroLink Host arrive from your Mac over Tailscale and install by themselves; nothing to do here."), + "Log files", + Some(&format!( + "Control port TCP {CONTROL_PORT}. The service and window logs roll over at 4 MB." + )), |ui| { - ui::muted(ui, format!("v{}", env!("CARGO_PKG_VERSION"))); + if ui::secondary_button(ui, "Open folder").clicked() { + self.open_log_folder(); + } }, ); ui::row_separator(ui); - ui::open_source_row(ui, &mut self.notices_open); - ui::row_separator(ui); - ui.horizontal(|ui| { - if ui::danger_button(ui, "Stop the background service").clicked() { - // Off the UI thread: the service may take a moment to - // answer, and the window must not freeze meanwhile. - std::thread::spawn(|| { - let _ = http::request( - ("127.0.0.1", CONTROL_PORT), - "POST", - "/v1/quit", - None, - Duration::from_secs(2), - ); - }); - let mut s = self.shared.lock(); - s.status = None; - s.service_error = - Some("Stopped. It starts again next time this window opens.".into()); - } - if ui::ghost_button(ui, "Open log folder").clicked() { - if let Ok(dir) = brolink_core::config::data_dir() { - let _ = std::process::Command::new("explorer").arg(dir).spawn(); + ui::setting_row( + ui, + "Background service", + Some("Stopping it stops sharing until this window opens again."), + |ui| { + if ui::danger_button(ui, "Stop service").clicked() { + self.stop_service(); } - } - }); + }, + ); }); } } +/// What this machine's NAT means for the others, in a sentence that names +/// the right kind of machine (the service log's version says "a Mac"). +fn network_sentence(n: &brolink_core::api::NatReport, noun: &str) -> String { + let city = brolink_core::tailscale::derp_city(&n.derp); + if !n.udp { + format!("UDP is blocked here, so other machines reach this {noun} only through a Tailscale relay.") + } else if n.hard == Some(true) && !n.portmap { + format!("Hard NAT without UPnP: machines on other networks reach this {noun} through the {city} relay, unless the router gets UPnP or a forwarded UDP port.") + } else if n.hard == Some(true) { + "Hard NAT, but the router maps ports, so direct connections should work.".into() + } else if n.hard == Some(false) { + format!("Easy NAT: direct connections should work. Nearest relay: {city}.") + } else { + format!("NAT type unknown. Nearest relay: {city}.") + } +} + +/// A setup failure in words: what happened, then what to do. +fn setup_error(e: &str, os: Os) -> String { + let lower = e.to_lowercase(); + if os == Os::Windows && lower.contains("declined") { + return "The administrator prompt was declined, or setup stopped part way. Try again and choose Yes; the setup log below shows the last step reached.".into(); + } + format!( + "{}. The setup log below shows the last step reached; run setup again once it is fixed.", + sentence_case(e.trim_end_matches('.')) + ) +} + +/// Capitalise the first letter. +fn sentence_case(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } +} + +/// The page's status in two words, and its tone. fn pill(status: Option<&Status>, err: Option<&str>) -> (&'static str, Tone) { match status { None if err.is_some() => ("Service stopped", Tone::Neutral), @@ -613,8 +924,8 @@ fn pill(status: Option<&Status>, err: Option<&str>) -> (&'static str, Tone) { Some(s) if s.setup.iter().any(|l| l.starts_with("Tailscale")) => { ("Tailscale off", Tone::Danger) } - Some(s) if !s.setup.is_empty() => ("Needs setup", Tone::Accent), - Some(_) => ("Ready", Tone::Success), + Some(s) if !s.setup.is_empty() => ("Needs setup", Tone::Warning), + Some(_) => ("Shared", Tone::Success), } } @@ -685,13 +996,17 @@ fn relaunch_this_exe() -> bool { } /// Poll the service every second; refresh Sunshine's client list now and -/// then; restart the service after an update. +/// then; restart the service after an update. The window repaints only +/// when an answer differs from the last. fn spawn_poller(shared: Arc>, ctx: egui::Context) { std::thread::spawn(move || { let mut failures = 0u32; let mut tick = 0u64; let mut restarted_service = false; loop { + // Repaint only when something the page shows has changed: an + // idle window draws nothing. + let mut changed = false; let r: Result = http::get_json( ("127.0.0.1", CONTROL_PORT), "/v1/status", @@ -731,28 +1046,28 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { let clients = api.clients(); let gamepad = api.gamepad_driver(); let mut s = shared.lock(); + changed |= s.clients != clients || s.gamepad_driver != gamepad; s.clients = clients; s.gamepad_driver = gamepad; } let mut s = shared.lock(); + changed |= s.status.as_ref() != Some(&st); s.status = Some(st); - if s.service_error - .as_deref() - .is_some_and(|e| !e.starts_with("Stopped")) - { + if s.service_error.as_deref().is_some_and(|e| e != STOPPED) { s.service_error = None; + changed = true; } } Err(e) => { failures += 1; let mut s = shared.lock(); - let stopped_by_user = s - .service_error - .as_deref() - .is_some_and(|e| e.starts_with("Stopped")); + let stopped_by_user = s.service_error.as_deref() == Some(STOPPED); + changed |= s.status.is_some(); s.status = None; if !stopped_by_user { - s.service_error = Some(format!("{e}")); + let e = format!("{e}"); + changed |= s.service_error.as_deref() != Some(e.as_str()); + s.service_error = Some(e); } drop(s); if failures % 4 == 2 && !stopped_by_user { @@ -760,7 +1075,10 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { } } } - ctx.request_repaint(); + if changed { + shared.lock().touch(); + ctx.request_repaint(); + } tick += 1; std::thread::sleep(Duration::from_secs(1)); } @@ -803,24 +1121,47 @@ mod tests { fn pill_reflects_setup_state() { assert_eq!(pill(None, None).0, "Starting"); let mut s = Status::default(); - assert_eq!(pill(Some(&s), None).0, "Ready"); + assert_eq!(pill(Some(&s), None), ("Shared", Tone::Success)); s.setup .push("The streaming engine is not installed.".into()); - assert_eq!(pill(Some(&s), None).0, "Needs setup"); + assert_eq!(pill(Some(&s), None), ("Needs setup", Tone::Warning)); s.setup.push("Tailscale: not running".into()); - assert_eq!(pill(Some(&s), None).0, "Tailscale off"); + assert_eq!(pill(Some(&s), None), ("Tailscale off", Tone::Danger)); + } + + #[test] + fn a_declined_prompt_says_what_to_do() { + let e = setup_error( + "the administrator prompt was declined or setup failed", + Os::Windows, + ); + assert!(e.contains("choose Yes"), "{e}"); + assert!(!e.contains("setup failed"), "no echo of the raw error: {e}"); + let e = setup_error("curl: (6) Could not resolve host", Os::Mac); + assert!(e.starts_with("Curl"), "{e}"); + assert!(e.contains("run setup again"), "{e}"); } } -/// Render the panel to PNGs for review without a PC: +/// The Sharing page inside the unified window, rendered without a PC: /// /// ```text /// cargo test -p brolink-host snapshots -- --ignored /// ``` +/// +/// Nothing here reads or writes real settings or reaches the service: the +/// window and the page are both built headless. #[cfg(test)] mod snapshots { use super::*; + use crate::product::NodeApp; + use brolink_client::app::Page; use brolink_core::api::Streamer; + use egui_kittest::kittest::Queryable; + + const MIN: egui::Vec2 = egui::vec2(640.0, 420.0); + const TYPICAL: egui::Vec2 = egui::vec2(1280.0, 800.0); + const LARGE: egui::Vec2 = egui::vec2(1920.0, 1200.0); fn out_dir() -> std::path::PathBuf { let dir = @@ -835,12 +1176,16 @@ mod snapshots { eprintln!("wrote {}", path.display()); } - fn ready_status() -> Status { + fn ready_status(os: &str) -> Status { Status { app: "brolink".into(), version: env!("CARGO_PKG_VERSION").into(), - name: "GAMING-PC".into(), - os: "windows".into(), + name: if os == "windows" { + "GAMING-PC".into() + } else { + "MacBook-Pro".into() + }, + os: os.into(), tailscale_ip: Some("100.64.0.10".into()), tailscale_login: Some("user@example.com".into()), lan_ip: Some("192.168.1.10".into()), @@ -855,7 +1200,11 @@ mod snapshots { installed: true, running: true, api_ok: true, - encoder: "nvenc".into(), + encoder: if os == "windows" { + "nvenc".into() + } else { + "videotoolbox".into() + }, audio_problem: String::new(), }, power_allowed: true, @@ -869,42 +1218,30 @@ mod snapshots { }), setup: vec![], log: vec![ - "BroLink Host 3.0.1 listening on TCP 47850".into(), + "BroLink 4.0.2 listening on TCP 47850".into(), "listening for wake packets on UDP 9".into(), "Tailscale up as user@example.com (100.64.0.10)".into(), - "BroLink is running and BroLink is logged in".into(), + "the streaming engine is running and BroLink is signed in".into(), "Wake-on-LAN ready on Ethernet (02:00:00:00:00:01)".into(), - "example-mac (user@example.com) asked".into(), - "paired \"Example Mac\"".into(), + "paired \"MacBook-Pro\"".into(), ], } } - fn build(shared: Shared) -> egui_kittest::Harness<'static, HostApp> { - build_sized(shared, egui::vec2(720.0, 1500.0), 2.0, true) - } - - fn build_sized( - shared: Shared, - size: egui::Vec2, - ppp: f32, - gpu: bool, - ) -> egui_kittest::Harness<'static, HostApp> { - let shared = Arc::new(Mutex::new(shared)); - let mut builder = egui_kittest::Harness::builder() - .with_size(size) - .with_pixels_per_point(ppp) - .with_max_steps(8); - if gpu { - builder = builder.wgpu(); + fn ready() -> Shared { + Shared { + status: Some(ready_status("windows")), + clients: vec![ + ("MacBook-Pro".into(), "0000000000000001".into()), + ("Studio".into(), "0000000000000002".into()), + ], + gamepad_driver: Some(false), + ..Default::default() } - let mut harness = builder.build_eframe(move |cc| HostApp::with_shared(cc, shared)); - harness.run_steps(3); - harness } - fn needs_setup_shared() -> Shared { - let mut st = ready_status(); + fn needs_setup() -> Shared { + let mut st = ready_status("windows"); st.streamer = Streamer::default(); st.wake_ready = Some(false); st.fast_startup = Some(true); @@ -920,10 +1257,108 @@ mod snapshots { } } + fn failed() -> Shared { + let mut st = ready_status("windows"); + st.tailscale_ip = None; + st.tailscale_login = None; + st.streamer = Streamer::default(); + st.setup = vec![ + "Tailscale: Tailscale is not installed. Install it and sign in.".into(), + "The streaming engine is not installed.".into(), + ]; + Shared { + status: Some(st), + setup_result: Some(Err("the administrator prompt was declined or setup failed (see C:\\Users\\Example User\\AppData\\Local\\BroLink\\setup.log)".into())), + setup_log: vec![ + "[14:02:11] BroLink setup started".into(), + "[14:02:11] Downloading the streaming engine".into(), + " engine: curl: (6) Could not resolve host: api.github.com".into(), + ], + ..Default::default() + } + } + + fn states() -> Vec<(&'static str, Os, Shared)> { + vec![ + ("ready", Os::Windows, ready()), + ("setup", Os::Windows, needs_setup()), + ( + "setup-running", + Os::Windows, + Shared { + setup_running: true, + ..needs_setup() + }, + ), + ("setup-failed", Os::Windows, failed()), + ( + "setup-done", + Os::Windows, + Shared { + setup_result: Some(Ok(())), + ..ready() + }, + ), + ( + "mac-ready", + Os::Mac, + Shared { + status: Some(ready_status("macOS")), + ..Default::default() + }, + ), + ( + "mac-setup", + Os::Mac, + Shared { + status: Some({ + let mut s = ready_status("macOS"); + s.streamer = Streamer::default(); + s.setup = vec!["The streaming engine is not installed.".into()]; + s + }), + ..Default::default() + }, + ), + ("starting", Os::Windows, Shared::default()), + ( + "stopped", + Os::Windows, + Shared { + service_error: Some(STOPPED.into()), + ..Default::default() + }, + ), + ] + } + + fn build( + shared: Shared, + os: Os, + size: egui::Vec2, + ppp: f32, + gpu: bool, + ) -> egui_kittest::Harness<'static, NodeApp> { + let mut builder = egui_kittest::Harness::builder() + .with_size(size) + .with_pixels_per_point(ppp) + .with_max_steps(8); + if gpu { + builder = builder.wgpu(); + } + let mut harness = builder.build_eframe(move |cc| { + let mut app = NodeApp::headless(cc, Default::default(), shared, os); + app.client_mut().open_page(Page::Sharing); + app + }); + harness.run_steps(3); + harness + } + /// Every widget sits inside the window, and no two controls overlap. - fn assert_fits(h: &egui_kittest::Harness<'_, HostApp>, width: f32) { + fn assert_fits(h: &egui_kittest::Harness<'_, NodeApp>, width: f32) { use egui::accesskit::Role; - use egui_kittest::kittest::{By, Queryable}; + use egui_kittest::kittest::By; let mut controls = Vec::new(); for node in h.query_all(By::new().predicate(|_| true)) { let Some(b) = node.raw_bounds() else { continue }; @@ -935,7 +1370,7 @@ mod snapshots { ); if matches!( node.role(), - Role::Button | Role::CheckBox | Role::RadioButton | Role::ComboBox + Role::Button | Role::CheckBox | Role::RadioButton | Role::ComboBox | Role::Link ) { controls.push(( text, @@ -958,73 +1393,120 @@ mod snapshots { } } - fn ready_shared() -> Shared { - Shared { - status: Some(ready_status()), - clients: vec![ - ("Example Mac".into(), "0000000000000001".into()), - ("Second Example Mac".into(), "0000000000000002".into()), - ], - gamepad_driver: Some(false), - ..Default::default() - } - } - #[test] - fn ready_panel_fits_the_minimum_window() { - let h = build_sized(ready_shared(), egui::vec2(560.0, 600.0), 1.0, false); - assert_fits(&h, 560.0); + fn every_sharing_state_fits_the_minimum_and_a_large_window() { + for (name, os, shared) in states() { + eprintln!("checking {name}"); + let h = build(shared, os, MIN, 1.0, false); + assert_fits(&h, MIN.x); + } + for (name, os, shared) in states() { + eprintln!("checking {name}"); + let h = build(shared, os, LARGE, 1.0, false); + assert_fits(&h, LARGE.x); + } } #[test] - fn setup_panel_fits_the_minimum_window() { - let h = build_sized(needs_setup_shared(), egui::vec2(560.0, 600.0), 1.0, false); - assert_fits(&h, 560.0); + fn the_page_is_a_tab_of_the_window_and_the_machine_leads_the_list() { + let mut h = build(ready(), Os::Windows, MIN, 1.0, false); + assert!(h.query_by_label("GAMING-PC").is_some()); + h.get_by_label("Machines").click(); + h.run_steps(3); + assert!( + h.query_by_label("GAMING-PC").is_some(), + "this PC leads the list" + ); + assert!(h.query_by_label("Manage").is_some()); } #[test] - #[ignore = "renders with a GPU; run on demand to review the UI"] - fn ready() { - let mut h = build(Shared { - status: Some(ready_status()), - clients: vec![ - ("Example Mac".into(), "0000000000000001".into()), - ("Second Example Mac".into(), "0000000000000002".into()), - ], - gamepad_driver: Some(true), - ..Default::default() - }); - save(h.render().unwrap(), "host-ready.png"); - let mut h = build_sized(ready_shared(), egui::vec2(560.0, 600.0), 2.0, true); - save(h.render().unwrap(), "host-ready-560.png"); + fn windows_only_rows_stay_off_a_mac() { + let tall = egui::vec2(1280.0, 2400.0); + let h = build(ready(), Os::Windows, tall, 1.0, false); + assert!(h.query_by_label("Wake-on-LAN").is_some()); + assert!(h + .query_all_by_label("Keep this PC awake while plugged in") + .next() + .is_some()); + let h = build( + Shared { + status: Some(ready_status("macOS")), + ..Default::default() + }, + Os::Mac, + tall, + 1.0, + false, + ); + assert!(h.query_by_label("Wake-on-LAN").is_none()); + assert!(h.query_by_label("Game controllers").is_none()); + assert!( + h.query_by_label_contains("Keep this").is_none(), + "keeping awake is Windows-only; the switch would do nothing on a Mac" + ); } #[test] - #[ignore = "renders with a GPU; run on demand to review the UI"] - fn needs_setup() { - let mut h = build(needs_setup_shared()); - save(h.render().unwrap(), "host-setup.png"); - let mut h = build_sized(needs_setup_shared(), egui::vec2(560.0, 600.0), 2.0, true); - save(h.render().unwrap(), "host-setup-560.png"); + fn unpair_asks_first_and_a_headless_page_touches_nothing() { + let tall = egui::vec2(1280.0, 2400.0); + let mut h = build(ready(), Os::Windows, tall, 1.0, false); + h.get_all_by_label("Unpair…").next().unwrap().click(); + h.run_steps(3); + assert!( + h.query_by_label("Unpair").is_some(), + "the confirming button" + ); + assert!(h.query_by_label("Keep").is_some()); + h.get_by_label("Run setup again").click(); + h.run_steps(3); + assert!( + h.query_by_label_contains("Waiting for the administrator prompt") + .is_some(), + "setup shows progress; headless, nothing ran" + ); } #[test] #[ignore = "renders with a GPU; run on demand to review the UI"] - fn setup_failed_no_tailscale() { - let mut st = ready_status(); - st.tailscale_ip = None; - st.tailscale_login = None; - st.setup = vec!["Tailscale: Tailscale is not installed. Install it and sign in.".into()]; - let mut h = build(Shared { - status: Some(st), - setup_result: Some(Err("the administrator prompt was declined or setup failed (see C:\\Users\\Example User\\AppData\\Local\\BroLink\\setup.log)".into())), - setup_log: vec![ - "[14:02:11] BroLink setup started".into(), - "[14:02:11] Downloading the streaming engine".into(), - " engine: curl: (6) Could not resolve host: api.github.com".into(), - ], - ..Default::default() - }); - save(h.render().unwrap(), "host-setup-failed.png"); + fn snapshots_sharing() { + let key = ["ready", "setup", "setup-failed"]; + for (name, os, _) in states() { + let sizes: Vec<(egui::Vec2, f32)> = if key.contains(&name) { + vec![ + (MIN, 1.0), + (MIN, 2.0), + (TYPICAL, 1.0), + (TYPICAL, 2.0), + (LARGE, 1.0), + (LARGE, 2.0), + ] + } else { + vec![(MIN, 2.0), (TYPICAL, 2.0)] + }; + for (size, ppp) in sizes { + let shared = states().into_iter().find(|(n, ..)| *n == name).unwrap().2; + let mut h = build(shared, os, size, ppp, true); + save( + h.render().unwrap(), + &format!( + "sharing-{name}-{}x{}@{}x.png", + size.x as u32, size.y as u32, ppp as u32 + ), + ); + } + } + for (name, os, shared) in states() { + let mut h = build(shared, os, egui::vec2(1280.0, 1800.0), 1.0, true); + save(h.render().unwrap(), &format!("sharing-{name}-full.png")); + } + let mut h = build(ready(), Os::Windows, egui::vec2(1280.0, 1800.0), 1.0, true); + h.get_by_label("Service log").click(); + h.run_steps(3); + save(h.render().unwrap(), "sharing-log-open-full.png"); + let mut h = build(ready(), Os::Windows, TYPICAL, 2.0, true); + h.get_by_label("Machines").click(); + h.run_steps(3); + save(h.render().unwrap(), "sharing-lobby-row-1280x800@2x.png"); } } diff --git a/crates/host/src/product.rs b/crates/host/src/product.rs index 6df904f..41d4219 100644 --- a/crates/host/src/product.rs +++ b/crates/host/src/product.rs @@ -1,53 +1,96 @@ -//! The one BroLink window: a machine list to connect from, and sharing of -//! this machine so others can connect in. +//! The one BroLink window: the machine list to connect from, this +//! machine's Sharing page, and settings. The viewer (`brolink-client`) +//! draws the window; the Sharing page is drawn by [`HostApp`] through +//! [`SharePage`], and a summary of it is handed over for the machine list. -use crate::app::HostApp; +use crate::app::{HostApp, Os, Shared}; use brolink_client::app::ClientApp; -use brolink_client::share::{LocalShare, Slot}; +use brolink_client::share::{LocalShare, SharePage, Slot}; use eframe::egui; use parking_lot::Mutex; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; pub struct NodeApp { client: ClientApp, - host: HostApp, + host: Rc>, local: Slot, + /// The host state last copied into `local`; copied again only when it + /// moves, so a stream does not pay for it every frame. + synced: Option, +} + +/// The Sharing page, as the viewer's window hosts it. +struct HostPage(Rc>); + +impl SharePage for HostPage { + fn show(&mut self, ui: &mut egui::Ui) { + self.0.borrow_mut().page(ui); + } } impl NodeApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + let client = ClientApp::new(cc); + let host = HostApp::new(cc); + Self::assemble(client, host) + } + + fn assemble(client: ClientApp, host: HostApp) -> Self { + let host = Rc::new(RefCell::new(host)); let local: Slot = Arc::new(Mutex::new(LocalShare::default())); - let client = ClientApp::new(cc).with_local(local.clone()); + let client = client.with_local(local.clone(), Box::new(HostPage(host.clone()))); Self { client, - host: HostApp::new(cc), + host, local, + synced: None, + } + } + + /// The whole window with no threads, no settings read and no actions + /// that reach the system, for rendering in tests. + #[doc(hidden)] + pub fn headless( + cc: &eframe::CreationContext<'_>, + discovery: brolink_client::session::Discovery, + shared: Shared, + os: Os, + ) -> Self { + let client = ClientApp::headless( + cc, + discovery, + Default::default(), + brolink_client::config::ClientConfig::default(), + ); + let host = HostApp::headless(Arc::new(Mutex::new(shared)), os); + Self::assemble(client, host) + } + + pub fn client_mut(&mut self) -> &mut ClientApp { + &mut self.client + } + + /// Copy what the machine list shows about this machine, when it has + /// changed since the last copy. + fn sync(&mut self) { + let host = self.host.borrow(); + let shared = host.shared(); + if self.synced == Some(shared.rev) { + return; } + self.synced = Some(shared.rev); + let mut g = self.local.lock(); + g.status = shared.status.clone(); + g.setup_running = shared.setup_running; + g.setup_result = shared.setup_result.clone(); } } impl eframe::App for NodeApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - { - let shared = self.host.shared(); - let mut g = self.local.lock(); - g.status = shared.status.clone(); - g.setup_running = shared.setup_running; - g.setup_result = shared.setup_result.clone(); - let want = g.want_setup; - g.want_setup = false; - let want_power = g.want_power.take(); - let want_stay = g.want_stay_awake.take(); - let want_auto = g.want_autostart.take(); - drop(g); - drop(shared); - if want { - self.host.request_setup(); - } - self.host - .apply_share_toggles(want_power, want_stay, want_auto); - self.host.publish_share(&self.local); - } + self.sync(); self.client.update(ctx, frame); } } diff --git a/crates/ui/assets/Geist-Medium.ttf b/crates/ui/assets/Geist-Medium.ttf new file mode 100644 index 0000000..96cb22f Binary files /dev/null and b/crates/ui/assets/Geist-Medium.ttf differ diff --git a/crates/ui/assets/Geist-Regular.ttf b/crates/ui/assets/Geist-Regular.ttf new file mode 100644 index 0000000..4da1b3a Binary files /dev/null and b/crates/ui/assets/Geist-Regular.ttf differ diff --git a/crates/ui/assets/Geist-SemiBold.ttf b/crates/ui/assets/Geist-SemiBold.ttf new file mode 100644 index 0000000..b2b0618 Binary files /dev/null and b/crates/ui/assets/Geist-SemiBold.ttf differ diff --git a/crates/ui/assets/GeistMono-Medium.ttf b/crates/ui/assets/GeistMono-Medium.ttf new file mode 100644 index 0000000..ff49ece Binary files /dev/null and b/crates/ui/assets/GeistMono-Medium.ttf differ diff --git a/crates/ui/assets/GeistMono-Regular.ttf b/crates/ui/assets/GeistMono-Regular.ttf new file mode 100644 index 0000000..50c9d5a Binary files /dev/null and b/crates/ui/assets/GeistMono-Regular.ttf differ diff --git a/crates/ui/assets/Inter-Medium.ttf b/crates/ui/assets/Inter-Medium.ttf deleted file mode 100644 index 458cd06..0000000 Binary files a/crates/ui/assets/Inter-Medium.ttf and /dev/null differ diff --git a/crates/ui/assets/Inter-Regular.ttf b/crates/ui/assets/Inter-Regular.ttf deleted file mode 100644 index b7aaca8..0000000 Binary files a/crates/ui/assets/Inter-Regular.ttf and /dev/null differ diff --git a/crates/ui/assets/Inter-SemiBold.ttf b/crates/ui/assets/Inter-SemiBold.ttf deleted file mode 100644 index 47f8ab1..0000000 Binary files a/crates/ui/assets/Inter-SemiBold.ttf and /dev/null differ diff --git a/crates/ui/assets/LICENSE-Inter.txt b/crates/ui/assets/LICENSE-Geist.txt similarity index 96% rename from crates/ui/assets/LICENSE-Inter.txt rename to crates/ui/assets/LICENSE-Geist.txt index 9b2ca37..04e95fc 100644 --- a/crates/ui/assets/LICENSE-Inter.txt +++ b/crates/ui/assets/LICENSE-Geist.txt @@ -1,8 +1,9 @@ -Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL +https://openfontlicense.org + ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 @@ -17,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The @@ -43,7 +44,7 @@ new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. -PERMISSION AND CONDITIONS +PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font @@ -89,4 +90,4 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 5453497..e18f4ad 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,14 +1,18 @@ -//! The look shared by the BroLink host and client. +//! BroLink's design system: the tokens and components every window and +//! overlay is built from. //! -//! Both apps are small egui programs aimed at people who are not developers. +//! The apps are small egui programs aimed at people who are not developers. //! egui's defaults read as a debugging tool, so this crate owns everything -//! that makes the two windows look like one product: the palette, the -//! bundled typeface, the widget styling, and a handful of composite widgets -//! (cards, list rows, status pills, the stream overlay) the screens are built -//! from. Keeping it here means the host and client cannot drift apart. +//! that makes the viewer, the Sharing page and the stream overlay look like +//! one product, and like the product site: the palette, the type scale on +//! Geist and Geist Mono, the spacing grid, radii and elevation +//! ([`theme`]), and the components screens are assembled from ([`widgets`]): +//! cards, grouped lists and rows, buttons in five kinds, menus, tabs, +//! switches, notices, tags, keycaps. Screens use these and nothing inline, +//! so the parts cannot drift apart. pub mod theme; pub mod widgets; -pub use theme::{apply, Palette, PALETTE}; +pub use theme::{apply, column, radius, size, space, Palette, PALETTE}; pub use widgets::*; diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index bcf6115..6dcba7b 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -1,94 +1,215 @@ -//! Palette, typography and widget styling. +//! Design tokens: colour, type, spacing, radii and elevation, and the egui +//! style built from them. //! -//! One dark theme, deliberately: the client spends its life next to a black -//! video surface, and the host is a status window people glance at. Everything -//! is derived from the handful of colours in [`Palette`], so a retune is a -//! change to one struct. +//! One dark theme, deliberately. The client spends its life next to a black +//! video surface and the product site (bardbro.com) is black, white and +//! hairlines, so the app is too: neutral surfaces a step apart, one text +//! colour in three strengths, white for the primary action and for "on", +//! and colour only where it carries meaning — green, amber and red for +//! status, and the logo's violet for keyboard focus, selection and +//! progress. Screens take every colour, size and distance from here; a +//! retune is a change to this file. use egui::{ - epaint::Shadow, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, - FontTweak, Margin, Stroke, TextStyle, Vec2, Visuals, + Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Margin, + Stroke, TextStyle, Vec2, Visuals, }; use std::sync::Arc; -/// The colours the two apps are built from. +// --------------------------------------------------------------------------- +// Colour +// --------------------------------------------------------------------------- + +/// Every colour the two windows and the stream overlay use. #[derive(Debug, Clone, Copy)] pub struct Palette { - /// Window background. + /// Window background (elevation 0). pub bg: Color32, - /// Cards and panels that sit on the background. + /// Grouped lists, cards and panels on the background (elevation 1). pub surface: Color32, - /// Inputs and buttons that sit on a card. + /// Controls on a surface: buttons, fields, the off track of a switch. pub raised: Color32, - /// The same, hovered. + /// A raised control under the pointer, and a hovered list row. pub raised_hover: Color32, - /// Hairlines around cards and controls. + /// A raised control being pressed. + pub pressed: Color32, + /// Sunken areas: logs and blocks of code. + pub well: Color32, + /// Floating panels over the stream picture. + pub overlay: Color32, + /// Separators between rows and around groups. + pub hairline: Color32, + /// Outlines of controls. pub border: Color32, - /// Hairlines that should read a little louder (hovered controls). + /// Outlines of hovered controls. pub border_strong: Color32, - /// Sunken areas: text boxes, the log. - pub well: Color32, - /// Primary text. + /// Primary text. 16:1 on `bg`. pub text: Color32, - /// Secondary text: hints, captions, metadata. - pub muted: Color32, - /// Tertiary text: disabled labels, placeholders. - pub faint: Color32, - /// Brand accent. Foreground colour: links, warnings, PIN digits, status - /// pills and selection. Readable at 4.5:1 on both `bg` and `surface`. + /// Secondary text: descriptions, metadata. 7:1 on `surface`. + pub text_secondary: Color32, + /// Tertiary text: labels, captions, small print. At least 4.5:1 on + /// every surface up to `raised_hover`. + pub text_tertiary: Color32, + /// Disabled labels. Exempt from contrast minimums; never the only cue. + pub text_disabled: Color32, + /// The primary button's fill and a switch that is on. + pub primary: Color32, + pub primary_hover: Color32, + pub primary_pressed: Color32, + /// Text and marks drawn on `primary`. + pub on_primary: Color32, + /// The logo's violet, lightened to read on the dark surfaces. Keyboard + /// focus, text selection, progress. Never a fill behind text. pub accent: Color32, - /// The accent as a solid fill (the primary button, the selected segment). - /// White text on this fill holds 4.5:1 in idle, hover and pressed states. - pub accent_fill: Color32, - /// Secondary brand colour from the logo (pink). Decorative only — never - /// carries text: white on this fill is 2.87:1. - pub accent2: Color32, - /// Text drawn on top of the accent fill. - pub on_accent: Color32, + /// Healthy, online, ready. pub success: Color32, + /// Needs attention; still works. + pub warning: Color32, + /// Broken, failed, destructive. Text on dark surfaces. pub danger: Color32, - pub info: Color32, + /// Fill behind white text on a destructive button. + pub danger_fill: Color32, } -/// Logo-derived dark palette (`logo.webp`: violet / pink / cyan on near-black). -/// -/// Locked tokens: bg `#07080c`, surface `#101219`, accent2 `#f05fd6`, -/// info `#22d3ee`, on_accent white. The logo violet is split into two roles: -/// `accent` (`#8d5ef6`, the `#8b5cf6` logo violet lightened) is foreground -/// text and reads 4.5:1 on both `bg` and `surface`; `accent_fill` (`#8257f6`, -/// the logo violet darkened) is the solid button fill whose white text holds -/// 4.5:1 in every state. One violet cannot do both at 4.5:1 — the luminance -/// windows are disjoint, so the roles are separate tokens. +/// The palette. Contrast ratios are checked by the tests at the bottom. pub const PALETTE: Palette = Palette { - bg: Color32::from_rgb(7, 8, 12), // #07080c - surface: Color32::from_rgb(16, 18, 25), // #101219 - raised: Color32::from_rgb(25, 28, 38), - raised_hover: Color32::from_rgb(34, 38, 50), - border: Color32::from_rgb(30, 34, 46), - border_strong: Color32::from_rgb(54, 58, 78), - well: Color32::from_rgb(5, 6, 10), - text: Color32::from_rgb(232, 236, 242), - muted: Color32::from_rgb(143, 154, 172), - faint: Color32::from_rgb(92, 102, 118), - accent: Color32::from_rgb(141, 94, 246), // #8d5ef6 - accent_fill: Color32::from_rgb(130, 87, 246), // #8257f6 - accent2: Color32::from_rgb(240, 95, 214), // #f05fd6 - on_accent: Color32::WHITE, - success: Color32::from_rgb(63, 185, 80), - danger: Color32::from_rgb(248, 81, 73), - info: Color32::from_rgb(34, 211, 238), // #22d3ee + bg: Color32::from_rgb(0x0a, 0x0a, 0x0a), + surface: Color32::from_rgb(0x11, 0x11, 0x11), + raised: Color32::from_rgb(0x1a, 0x1a, 0x1a), + raised_hover: Color32::from_rgb(0x24, 0x24, 0x24), + pressed: Color32::from_rgb(0x2e, 0x2e, 0x2e), + well: Color32::from_rgb(0x06, 0x06, 0x06), + overlay: Color32::from_rgba_premultiplied(0x08, 0x08, 0x08, 0xeb), + hairline: Color32::from_rgb(0x24, 0x24, 0x24), + border: Color32::from_rgb(0x36, 0x36, 0x36), + border_strong: Color32::from_rgb(0x4a, 0x4a, 0x4a), + text: Color32::from_rgb(0xed, 0xed, 0xed), + text_secondary: Color32::from_rgb(0xa1, 0xa1, 0xa1), + text_tertiary: Color32::from_rgb(0x8c, 0x8c, 0x8c), + text_disabled: Color32::from_rgb(0x5e, 0x5e, 0x5e), + primary: Color32::from_rgb(0xed, 0xed, 0xed), + primary_hover: Color32::from_rgb(0xd4, 0xd4, 0xd4), + primary_pressed: Color32::from_rgb(0xbd, 0xbd, 0xbd), + on_primary: Color32::from_rgb(0x0a, 0x0a, 0x0a), + accent: Color32::from_rgb(0xa7, 0x8b, 0xfa), + success: Color32::from_rgb(0x4c, 0xc3, 0x8a), + warning: Color32::from_rgb(0xf0, 0xb2, 0x49), + danger: Color32::from_rgb(0xff, 0x63, 0x69), + danger_fill: Color32::from_rgb(0xcd, 0x2b, 0x31), }; -/// Font families registered by [`apply`], for text that needs a specific -/// weight. Everything else goes through [`FontFamily::Proportional`], which -/// resolves to Inter Regular. -pub const MEDIUM: &str = "Inter-Medium"; -pub const SEMIBOLD: &str = "Inter-SemiBold"; +// --------------------------------------------------------------------------- +// Spacing, sizes, radii +// --------------------------------------------------------------------------- -/// Named text styles beyond egui's built-ins. -pub const TITLE: &str = "title"; -pub const DISPLAY: &str = "display"; -pub const CAPTION: &str = "caption"; +/// The spacing scale, on a 4-point grid. Layout uses these and nothing in +/// between. +pub mod space { + pub const XXS: f32 = 2.0; + pub const XS: f32 = 4.0; + pub const SM: f32 = 8.0; + pub const MD: f32 = 12.0; + pub const LG: f32 = 16.0; + pub const XL: f32 = 24.0; + pub const XXL: f32 = 32.0; + pub const XXXL: f32 = 48.0; +} + +/// Fixed sizes of recurring elements. +pub mod size { + use egui::Vec2; + /// Height of a button, field or select. + pub const CONTROL: f32 = 32.0; + /// Height of a compact control (the stream toolbar, menus). + pub const CONTROL_SM: f32 = 28.0; + /// The smallest target a pointer is asked to hit (WCAG 2.5.8). + pub const HIT_MIN: f32 = 24.0; + /// Height of the window's top bar. + pub const TOP_BAR: f32 = 48.0; + /// Height of the stream toolbar. + pub const TOOLBAR: f32 = 44.0; + /// Minimum height of a row in a list or a settings group. + pub const ROW: f32 = 44.0; + /// Diameter of a status dot. + pub const DOT: f32 = 8.0; + /// Width of the switch control. + pub const SWITCH: Vec2 = Vec2::new(36.0, 20.0); +} + +/// Corner radii. Small and consistent: controls are nearly square, as on +/// the product site; only floating panels round off more. +pub mod radius { + /// Buttons, fields, keycaps, tags. + pub const SM: u8 = 4; + /// Cards, grouped lists, notices, menus. + pub const MD: u8 = 6; + /// Floating panels over the stream. + pub const LG: u8 = 10; +} + +/// Maximum widths of the content column, per kind of page. +pub mod column { + /// Lists: the machine list. + pub const WIDE: f32 = 880.0; + /// Forms: settings, sharing. + pub const NARROW: f32 = 720.0; +} + +/// Shadows, by how far a layer floats above the window. +pub mod elevation { + use egui::{epaint::Shadow, Color32}; + /// Menus, dropdowns, tooltips. + pub const POPUP: Shadow = Shadow { + offset: [0, 8], + blur: 24, + spread: 0, + color: Color32::from_black_alpha(140), + }; + /// Panels floating over the stream picture. + pub const OVERLAY: Shadow = Shadow { + offset: [0, 12], + blur: 32, + spread: 0, + color: Color32::from_black_alpha(160), + }; +} + +// --------------------------------------------------------------------------- +// Type +// --------------------------------------------------------------------------- + +/// Font families registered by [`apply`]. [`FontFamily::Proportional`] is +/// Geist Regular and [`FontFamily::Monospace`] is Geist Mono Regular; these +/// name the heavier cuts. +pub const MEDIUM: &str = "Geist-Medium"; +pub const SEMIBOLD: &str = "Geist-SemiBold"; +pub const MONO_MEDIUM: &str = "GeistMono-Medium"; + +/// The type scale, in points. Every piece of text uses one of these. +pub mod text { + /// A page's title: "Machines", "Settings", a machine's name. + pub const PAGE_TITLE: f32 = 22.0; + /// A card or dialog title. + pub const TITLE: f32 = 15.0; + /// Running text and controls. + pub const BODY: f32 = 14.0; + /// Descriptions under a label, metadata, small print. + pub const CAPTION: f32 = 12.5; + /// Mono uppercase section labels, as on the product site. + pub const LABEL: f32 = 11.0; + /// Mono data: addresses, versions, rates, logs. + pub const MONO: f32 = 12.5; + /// The pairing PIN. + pub const DISPLAY: f32 = 32.0; + /// Tracking of the mono uppercase labels (0.06 em at 11 pt). + pub const LABEL_TRACKING: f32 = 0.66; + /// Tracking of page titles (-0.02 em). + pub const TITLE_TRACKING: f32 = -0.44; +} + +pub fn regular(size: f32) -> FontId { + FontId::proportional(size) +} pub fn medium(size: f32) -> FontId { FontId::new(size, FontFamily::Name(MEDIUM.into())) @@ -98,12 +219,20 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name(SEMIBOLD.into())) } -pub fn title() -> TextStyle { - TextStyle::Name(TITLE.into()) +pub fn mono(size: f32) -> FontId { + FontId::monospace(size) +} + +pub fn mono_medium(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(MONO_MEDIUM.into())) } -pub fn display() -> TextStyle { - TextStyle::Name(DISPLAY.into()) +/// Named text styles beyond egui's built-ins. +pub const TITLE: &str = "title"; +pub const CAPTION: &str = "caption"; + +pub fn title() -> TextStyle { + TextStyle::Name(TITLE.into()) } pub fn caption() -> TextStyle { @@ -116,10 +245,26 @@ pub fn stroke(width: f32, color: Color32) -> Stroke { Stroke::new(width, color) } -/// Corner radius used for cards and other large containers. -pub const RADIUS_LG: u8 = 12; -/// Corner radius used for controls. -pub const RADIUS: u8 = 8; +/// The scroll bar of a menu or panel whose content is cut off by the +/// window: the window's usual floating bar, but with its handle showing at +/// rest, so a clipped list says there is more below without a hover. +pub fn clipped_scroll() -> egui::style::ScrollStyle { + egui::style::ScrollStyle { + floating: true, + bar_width: 6.0, + floating_width: 3.0, + floating_allocated_width: 0.0, + bar_inner_margin: space::XXS, + foreground_color: true, + dormant_background_opacity: 0.0, + dormant_handle_opacity: 0.45, + active_background_opacity: 0.0, + active_handle_opacity: 0.6, + interact_background_opacity: 0.2, + interact_handle_opacity: 0.8, + ..egui::style::ScrollStyle::floating() + } +} /// Install the fonts and style on a context. Call once at startup. pub fn apply(ctx: &egui::Context) { @@ -127,93 +272,113 @@ pub fn apply(ctx: &egui::Context) { let mut style = (*ctx.style()).clone(); style.visuals = visuals(); style.text_styles = [ - (TextStyle::Small, FontId::proportional(12.0)), - (TextStyle::Body, FontId::proportional(14.0)), - (TextStyle::Button, medium(14.0)), - (TextStyle::Heading, semibold(20.0)), - (TextStyle::Monospace, FontId::monospace(13.0)), - (title(), semibold(15.0)), - (display(), semibold(40.0)), - (caption(), FontId::proportional(12.5)), + (TextStyle::Small, regular(text::CAPTION)), + (TextStyle::Body, regular(text::BODY)), + (TextStyle::Button, medium(text::BODY)), + (TextStyle::Heading, semibold(text::PAGE_TITLE)), + (TextStyle::Monospace, mono(text::MONO)), + (title(), semibold(text::TITLE)), + (caption(), regular(text::CAPTION)), ] .into(); let sp = &mut style.spacing; - sp.item_spacing = Vec2::new(8.0, 8.0); - sp.button_padding = Vec2::new(14.0, 7.0); - sp.interact_size = Vec2::new(40.0, 30.0); - sp.indent = 18.0; - sp.slider_width = 220.0; - sp.slider_rail_height = 6.0; - sp.text_edit_width = 320.0; - sp.combo_width = 140.0; + sp.item_spacing = Vec2::new(space::SM, space::SM); + sp.button_padding = Vec2::new(space::MD, 6.0); + sp.interact_size = Vec2::new(40.0, size::CONTROL); + sp.indent = space::LG; + sp.slider_width = 200.0; + sp.slider_rail_height = 4.0; + sp.text_edit_width = 240.0; + sp.combo_width = 160.0; + sp.combo_height = 320.0; sp.icon_width = 16.0; sp.icon_width_inner = 10.0; sp.icon_spacing = 6.0; - sp.window_margin = Margin::same(14); - sp.menu_margin = Margin::same(8); - sp.tooltip_width = 360.0; - sp.scroll.bar_width = 8.0; + sp.menu_width = 220.0; + sp.menu_spacing = space::XS; + sp.window_margin = Margin::same(space::LG as i8); + sp.menu_margin = Margin::same(space::XS as i8); + sp.tooltip_width = 320.0; + sp.scroll.bar_width = 6.0; sp.scroll.floating = true; - sp.scroll.bar_inner_margin = 4.0; + sp.scroll.bar_inner_margin = space::XS; style.url_in_tooltip = true; + // Popups, menus and tooltips appear and go at once: a fade would ask + // for repaints the stream view does not want. + style.animation_time = 0.1; ctx.set_style(style); } +fn font(bytes: &'static [u8]) -> Arc { + // Geist sits a hair high on egui's line box; nudge it so text centres + // in buttons and rows. + let mut data = FontData::from_static(bytes); + data.tweak = FontTweak { + y_offset_factor: 0.04, + ..Default::default() + }; + Arc::new(data) +} + fn fonts() -> FontDefinitions { let mut fonts = FontDefinitions::default(); - // Inter is bundled so the two apps look the same on every machine; the - // egui defaults stay in the family lists as fallbacks for symbols and - // emoji Inter does not carry. - fonts.font_data.insert( - "Inter".into(), - Arc::new(tweaked(FontData::from_static(include_bytes!( - "../assets/Inter-Regular.ttf" - )))), + // Geist and Geist Mono are bundled so every machine draws the same + // letters as the product site. egui's own fonts stay at the end of each + // family as fallbacks for glyphs Geist does not carry. + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + fonts.font_data.insert(name.into(), font(bytes)); + }; + add( + &mut fonts, + "Geist", + include_bytes!("../assets/Geist-Regular.ttf"), + ); + add( + &mut fonts, + MEDIUM, + include_bytes!("../assets/Geist-Medium.ttf"), + ); + add( + &mut fonts, + SEMIBOLD, + include_bytes!("../assets/Geist-SemiBold.ttf"), ); - fonts.font_data.insert( - MEDIUM.into(), - Arc::new(tweaked(FontData::from_static(include_bytes!( - "../assets/Inter-Medium.ttf" - )))), + add( + &mut fonts, + "GeistMono", + include_bytes!("../assets/GeistMono-Regular.ttf"), ); - fonts.font_data.insert( - SEMIBOLD.into(), - Arc::new(tweaked(FontData::from_static(include_bytes!( - "../assets/Inter-SemiBold.ttf" - )))), + add( + &mut fonts, + MONO_MEDIUM, + include_bytes!("../assets/GeistMono-Medium.ttf"), ); - let fallbacks: Vec = fonts - .families - .get(&FontFamily::Proportional) - .cloned() - .unwrap_or_default(); - let with = |name: &str| { + let fallbacks = |family: FontFamily| -> Vec { + fonts.families.get(&family).cloned().unwrap_or_default() + }; + let sans = fallbacks(FontFamily::Proportional); + let monos = fallbacks(FontFamily::Monospace); + let with = |name: &str, rest: &[String]| { let mut v = vec![name.to_string()]; - v.extend(fallbacks.iter().cloned()); + v.extend(rest.iter().cloned()); v }; - fonts - .families - .insert(FontFamily::Proportional, with("Inter")); - fonts - .families - .insert(FontFamily::Name(MEDIUM.into()), with(MEDIUM)); - fonts - .families - .insert(FontFamily::Name(SEMIBOLD.into()), with(SEMIBOLD)); + let families = [ + (FontFamily::Proportional, with("Geist", &sans)), + (FontFamily::Name(MEDIUM.into()), with(MEDIUM, &sans)), + (FontFamily::Name(SEMIBOLD.into()), with(SEMIBOLD, &sans)), + (FontFamily::Monospace, with("GeistMono", &monos)), + ( + FontFamily::Name(MONO_MEDIUM.into()), + with(MONO_MEDIUM, &monos), + ), + ]; + for (family, list) in families { + fonts.families.insert(family, list); + } fonts } -/// Inter sits a touch high on its line at egui's default metrics; nudge it -/// so text centres in buttons and pills. -fn tweaked(mut data: FontData) -> FontData { - data.tweak = FontTweak { - y_offset_factor: 0.02, - ..Default::default() - }; - data -} - fn visuals() -> Visuals { let p = PALETTE; let mut v = Visuals::dark(); @@ -221,25 +386,15 @@ fn visuals() -> Visuals { v.panel_fill = p.bg; v.window_fill = p.surface; v.window_stroke = stroke(1.0, p.border); - v.window_corner_radius = CornerRadius::same(RADIUS_LG); - v.window_shadow = Shadow { - offset: [0, 8], - blur: 24, - spread: 0, - color: Color32::from_black_alpha(120), - }; - v.popup_shadow = Shadow { - offset: [0, 6], - blur: 18, - spread: 0, - color: Color32::from_black_alpha(110), - }; - v.menu_corner_radius = CornerRadius::same(10); + v.window_corner_radius = CornerRadius::same(radius::LG); + v.window_shadow = elevation::OVERLAY; + v.popup_shadow = elevation::POPUP; + v.menu_corner_radius = CornerRadius::same(radius::MD); v.extreme_bg_color = p.well; v.faint_bg_color = p.raised; v.code_bg_color = p.well; - v.hyperlink_color = p.accent; - v.warn_fg_color = p.accent; + v.hyperlink_color = p.text; + v.warn_fg_color = p.warning; v.error_fg_color = p.danger; v.button_frame = true; v.collapsing_header_frame = false; @@ -247,21 +402,25 @@ fn visuals() -> Visuals { v.striped = false; v.slider_trailing_fill = true; v.handle_shape = egui::style::HandleShape::Circle; - v.selection.bg_fill = p.accent.gamma_multiply(0.35); + v.selection.bg_fill = p.accent.gamma_multiply(0.30); v.selection.stroke = stroke(1.0, p.accent); v.text_cursor.stroke = stroke(2.0, p.accent); + v.interact_cursor = Some(egui::CursorIcon::PointingHand); + // A focus ring is drawn two points outside its control; a window or a + // scroll area clipped tight to its content would cut it off at the edge. + v.clip_rect_margin = 4.0; let w = &mut v.widgets; - let r = CornerRadius::same(RADIUS); + let r = CornerRadius::same(radius::SM); w.noninteractive.bg_fill = p.surface; w.noninteractive.weak_bg_fill = p.surface; - w.noninteractive.bg_stroke = stroke(1.0, p.border); - w.noninteractive.fg_stroke = stroke(1.0, p.text); + w.noninteractive.bg_stroke = stroke(1.0, p.hairline); + w.noninteractive.fg_stroke = stroke(1.0, p.text_secondary); w.noninteractive.corner_radius = r; w.inactive.bg_fill = p.raised; w.inactive.weak_bg_fill = p.raised; - w.inactive.bg_stroke = stroke(1.0, p.border_strong.gamma_multiply(0.6)); + w.inactive.bg_stroke = stroke(1.0, p.border); w.inactive.fg_stroke = stroke(1.0, p.text); w.inactive.corner_radius = r; w.inactive.expansion = 0.0; @@ -269,14 +428,16 @@ fn visuals() -> Visuals { w.hovered.bg_fill = p.raised_hover; w.hovered.weak_bg_fill = p.raised_hover; w.hovered.bg_stroke = stroke(1.0, p.border_strong); - w.hovered.fg_stroke = stroke(1.5, p.text); + w.hovered.fg_stroke = stroke(1.0, p.text); w.hovered.corner_radius = r; w.hovered.expansion = 0.0; - w.active.bg_fill = p.border_strong; - w.active.weak_bg_fill = p.border_strong; - w.active.bg_stroke = stroke(1.0, p.border_strong); - w.active.fg_stroke = stroke(1.5, p.text); + // egui draws a focused built-in widget (select, slider, menu item) with + // the active visuals, so this outline is also their focus ring. + w.active.bg_fill = p.pressed; + w.active.weak_bg_fill = p.pressed; + w.active.bg_stroke = stroke(1.5, p.accent); + w.active.fg_stroke = stroke(1.0, p.text); w.active.corner_radius = r; w.active.expansion = 0.0; @@ -288,10 +449,8 @@ fn visuals() -> Visuals { v } -#[cfg(test)] -mod tests { - use super::*; - +/// WCAG 2 contrast ratio between two opaque colours. +pub fn contrast(fg: Color32, bg: Color32) -> f64 { fn lin(c: u8) -> f64 { let s = f64::from(c) / 255.0; if s <= 0.04045 { @@ -300,42 +459,75 @@ mod tests { ((s + 0.055) / 1.055).powf(2.4) } } + let lum = |c: Color32| 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()); + let (a, b) = (lum(fg), lum(bg)); + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; + (hi + 0.05) / (lo + 0.05) +} - fn lum(c: Color32) -> f64 { - 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) - } +#[cfg(test)] +mod tests { + use super::*; - fn contrast(fg: Color32, bg: Color32) -> f64 { - let (a, b) = (lum(fg), lum(bg)); - let (hi, lo) = if a > b { (a, b) } else { (b, a) }; - (hi + 0.05) / (lo + 0.05) + const AA: f64 = 4.5; + /// WCAG 1.4.11: meaningful non-text marks (status dots, focus rings). + const NON_TEXT: f64 = 3.0; + + #[test] + fn every_text_colour_meets_aa_on_every_surface_it_sits_on() { + let p = PALETTE; + let surfaces = [p.bg, p.surface, p.raised, p.raised_hover, Color32::BLACK]; + for bg in surfaces { + for (name, fg) in [ + ("text", p.text), + ("secondary", p.text_secondary), + ("tertiary", p.text_tertiary), + ("success", p.success), + ("warning", p.warning), + ("danger", p.danger), + ("accent", p.accent), + ] { + let c = contrast(fg, bg); + assert!(c >= AA, "{name} on {bg:?} is {c:.2}:1"); + } + } } #[test] - fn logo_tokens_match_specified_hex() { + fn filled_buttons_keep_their_label_readable_in_every_state() { let p = PALETTE; - assert_eq!(p.bg, Color32::from_rgb(7, 8, 12)); - assert_eq!(p.surface, Color32::from_rgb(16, 18, 25)); - assert_eq!(p.accent, Color32::from_rgb(141, 94, 246)); - assert_eq!(p.accent_fill, Color32::from_rgb(130, 87, 246)); - assert_eq!(p.accent2, Color32::from_rgb(240, 95, 214)); - assert_eq!(p.info, Color32::from_rgb(34, 211, 238)); - assert_eq!(p.on_accent, Color32::WHITE); - assert_ne!(p.well, p.bg); + for fill in [p.primary, p.primary_hover, p.primary_pressed] { + assert!(contrast(p.on_primary, fill) >= AA, "{fill:?}"); + } + assert!(contrast(Color32::WHITE, p.danger_fill) >= AA); } #[test] - fn text_on_surfaces_meets_aa() { + fn focus_and_status_marks_stand_out_from_the_background() { let p = PALETTE; - assert!(contrast(p.text, p.bg) >= 7.0); - assert!(contrast(p.text, p.surface) >= 7.0); - assert!(contrast(p.muted, p.bg) >= 4.5); - assert!(contrast(p.info, p.bg) >= 4.5); - assert!(contrast(p.accent2, p.bg) >= 4.5); - // Foreground accent (links, warnings, pills) must read on both. - assert!(contrast(p.accent, p.bg) >= 4.5); - assert!(contrast(p.accent, p.surface) >= 4.5); - // White button text on the fill must read at 4.5:1. - assert!(contrast(p.on_accent, p.accent_fill) >= 4.5); + for mark in [p.accent, p.success, p.warning, p.danger, p.text_tertiary] { + assert!(contrast(mark, p.bg) >= NON_TEXT, "{mark:?}"); + assert!(contrast(mark, p.surface) >= NON_TEXT, "{mark:?}"); + } + // The focus ring is drawn outside a control, two points clear of + // it, so it is always seen against the background. + } + + #[test] + fn the_scales_step_on_the_grid() { + for s in [ + space::XS, + space::SM, + space::MD, + space::LG, + space::XL, + space::XXL, + space::XXXL, + ] { + assert_eq!(s % 4.0, 0.0, "{s} is off the 4-point grid"); + } + const { assert!(radius::SM < radius::MD && radius::MD < radius::LG) }; + const { assert!(size::HIT_MIN <= size::SWITCH.y + 4.0) }; + const { assert!(size::CONTROL_SM >= size::HIT_MIN) }; } } diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index ccdfa6e..33abd74 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -1,52 +1,158 @@ -//! Composite widgets the host and client screens are assembled from. +//! The components every BroLink screen is assembled from. //! -//! Everything here is drawn from [`PALETTE`](crate::PALETTE) and the text -//! styles registered by [`apply`](crate::apply); none of it carries its own -//! colours, so the two apps stay in step. +//! Everything here takes its colours, sizes and distances from +//! [`theme`](crate::theme); nothing carries its own, so the machine list, +//! the sharing page and the stream overlay stay one product. Custom-painted +//! widgets report themselves to accesskit and draw a focus ring, so the +//! whole window works from the keyboard and with a screen reader. -use crate::theme::{self, stroke, PALETTE as P, RADIUS, RADIUS_LG}; +use crate::theme::{self, radius, size, space, stroke, text, PALETTE as P}; use egui::{ - collapsing_header::CollapsingState, Align, Color32, ColorImage, CornerRadius, Frame, Id, - InnerResponse, Label, Layout, Margin, Rect, Response, RichText, Sense, Stroke, StrokeKind, - TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, WidgetInfo, WidgetType, + collapsing_header::CollapsingState, Align, Align2, Color32, ColorImage, CornerRadius, Frame, + Id, InnerResponse, Label, Layout, Margin, Painter, Pos2, Rect, Response, RichText, Sense, + Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, WidgetInfo, + WidgetText, WidgetType, }; -/// Semantic colour for pills, notices and status text. +/// What a mark, a notice or a status line means. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tone { + /// Plain information; no judgement. Neutral, + /// Something happened that is worth a line; drawn like `Neutral`. + Info, + /// In progress: connecting, waking, pairing. Accent, + /// Online, ready, done. Success, + /// Works, but something could be better or needs a decision. + Warning, + /// Failed or unreachable. Danger, - Info, } impl Tone { + /// The colour of this tone's mark and, for text in that tone, its ink. pub fn color(self) -> Color32 { match self { - Tone::Neutral => P.muted, + Tone::Neutral | Tone::Info => P.text_tertiary, Tone::Accent => P.accent, Tone::Success => P.success, + Tone::Warning => P.warning, Tone::Danger => P.danger, - Tone::Info => P.info, } } + + /// The outline of a card or notice in this tone. + fn line(self) -> Color32 { + match self { + Tone::Neutral | Tone::Info => P.border, + t => t.color().gamma_multiply(0.5), + } + } + + /// The faint wash behind a notice in this tone. + fn wash(self) -> Color32 { + match self { + Tone::Neutral | Tone::Info => P.surface, + t => t.color().gamma_multiply(0.07), + } + } +} + +fn margin(x: f32, y: f32) -> Margin { + Margin::symmetric(x as i8, y as i8) +} + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +/// Small line icons, painted rather than taken from a font so they match +/// on every machine and at every scale. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Icon { + ChevronDown, + ChevronRight, + /// Three dots: more actions. + More, + Check, + Close, + /// Opens elsewhere (a browser). + External, +} + +/// Paint `icon` in a 12-point box centred on `c`. +fn paint_icon(painter: &Painter, c: Pos2, icon: Icon, color: Color32) { + let s = stroke(1.5, color); + let p = |x: f32, y: f32| c + Vec2::new(x, y); + match icon { + Icon::ChevronDown => { + painter.add(Shape::line( + vec![p(-4.0, -2.0), p(0.0, 2.0), p(4.0, -2.0)], + s, + )); + } + Icon::ChevronRight => { + painter.add(Shape::line( + vec![p(-2.0, -4.0), p(2.0, 0.0), p(-2.0, 4.0)], + s, + )); + } + Icon::More => { + for x in [-5.0, 0.0, 5.0] { + painter.circle_filled(p(x, 0.0), 1.5, color); + } + } + Icon::Check => { + painter.add(Shape::line( + vec![p(-4.0, 0.0), p(-1.5, 3.0), p(4.0, -3.5)], + s, + )); + } + Icon::Close => { + painter.line_segment([p(-4.0, -4.0), p(4.0, 4.0)], s); + painter.line_segment([p(-4.0, 4.0), p(4.0, -4.0)], s); + } + Icon::External => { + painter.line_segment([p(-3.5, 3.5), p(3.5, -3.5)], s); + painter.add(Shape::line( + vec![p(-1.5, -3.5), p(3.5, -3.5), p(3.5, 1.5)], + s, + )); + } + } +} + +/// Keep keyboard navigation visible for controls with custom painting: an +/// accent outline two points outside the control, so it shows against the +/// background whatever the control's own fill. +fn focus_ring(ui: &Ui, response: &Response, corner: u8) { + if response.has_focus() && ui.is_enabled() { + ui.painter().rect_stroke( + response.rect.expand(2.0), + CornerRadius::same(corner.saturating_add(2)), + stroke(1.5, P.accent), + StrokeKind::Outside, + ); + } } // --------------------------------------------------------------------------- // Containers // --------------------------------------------------------------------------- -/// The frame every card is drawn with. -pub fn card_frame() -> Frame { +/// The frame of a card: a surface one step up from the window, with a +/// hairline around it. +fn card_frame() -> Frame { Frame::new() .fill(P.surface) - .stroke(stroke(1.0, P.border)) - .corner_radius(CornerRadius::same(RADIUS_LG)) - .inner_margin(Margin::same(18)) + .stroke(stroke(1.0, P.hairline)) + .corner_radius(CornerRadius::same(radius::MD)) + .inner_margin(Margin::same(space::LG as i8)) } -/// A full-width surface with a hairline border. +/// A full-width card for free-form content. pub fn card(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { card_frame().show(ui, |ui| { ui.set_min_width(ui.available_width()); @@ -54,51 +160,100 @@ pub fn card(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse }) } -/// A card whose border carries a tone, for the one thing on screen that -/// needs attention right now (a pairing PIN, a setup problem). +/// A card whose outline carries a tone, for the one thing on screen that +/// needs attention now (a connection in progress, a failure, setup). pub fn toned_card(ui: &mut Ui, tone: Tone, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { card_frame() - .stroke(stroke(1.0, tone.color().gamma_multiply(0.7))) + .stroke(stroke(1.0, tone.line())) .show(ui, |ui| { ui.set_min_width(ui.available_width()); add(ui) }) } -/// Card title with an optional one-line explanation underneath. +/// A card's title with an optional one-line explanation underneath. pub fn heading(ui: &mut Ui, title: &str, subtitle: Option<&str>) { - ui.label( - RichText::new(title) - .text_style(theme::title()) - .color(P.text), + ui.add( + Label::new( + RichText::new(title) + .font(theme::semibold(text::TITLE)) + .color(P.text), + ) + .wrap(), ); if let Some(s) = subtitle { - ui.add_space(-2.0); + ui.add_space(-space::XS); caption(ui, s); } - ui.add_space(8.0); + ui.add_space(space::XS); } -/// [`card`] with a [`heading`]. -pub fn titled_card( - ui: &mut Ui, - title: &str, - subtitle: Option<&str>, - add: impl FnOnce(&mut Ui) -> R, -) -> InnerResponse { - card(ui, |ui| { - heading(ui, title, subtitle); - add(ui) +/// A grouped list: rows separated by hairlines on one surface, as in the +/// machine list and every settings section. Rows bring their own padding. +pub fn group(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + Frame::new() + .fill(P.surface) + .stroke(stroke(1.0, P.hairline)) + .corner_radius(CornerRadius::same(radius::MD)) + .inner_margin(margin(space::LG, 0.0)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 0.0; + add(ui) + }) +} + +/// A mono uppercase label over a section, as on the product site. +pub fn section_label(ui: &mut Ui, label: &str) -> Response { + ui.add( + Label::new( + RichText::new(label.to_uppercase()) + .font(theme::mono_medium(text::LABEL)) + .extra_letter_spacing(text::LABEL_TRACKING) + .color(P.text_tertiary), + ) + .truncate(), + ) +} + +/// A [`section_label`] over a [`group`]. +pub fn section(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = space::SM; + section_label(ui, label); + group(ui, add).inner }) + .inner +} + +/// A page's title, an optional line under it, and controls on the right. +pub fn page_header(ui: &mut Ui, title: &str, subtitle: Option<&str>, right: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = space::MD; + ui.add( + Label::new( + RichText::new(title) + .font(theme::semibold(text::PAGE_TITLE)) + .extra_letter_spacing(text::TITLE_TRACKING) + .color(P.text), + ) + .truncate(), + ); + ui.with_layout(Layout::right_to_left(Align::Center), right); + }); + if let Some(s) = subtitle { + ui.add_space(-space::XS); + muted(ui, s); + } } -/// A sunken area for a log or a block of monospace text. +/// A sunken area for a log or a block of code. pub fn well(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { Frame::new() .fill(P.well) - .stroke(stroke(1.0, P.border)) - .corner_radius(CornerRadius::same(RADIUS)) - .inner_margin(Margin::symmetric(12, 10)) + .stroke(stroke(1.0, P.hairline)) + .corner_radius(CornerRadius::same(radius::SM)) + .inner_margin(margin(space::MD, space::SM)) .show(ui, |ui| { ui.set_min_width(ui.available_width()); add(ui) @@ -108,7 +263,7 @@ pub fn well(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse /// A scrolling log in a [`well`], newest line at the bottom. pub fn log_view(ui: &mut Ui, id: impl std::hash::Hash, lines: &[String], max_height: f32) { well(ui, |ui| { - ui.spacing_mut().item_spacing.y = 3.0; + ui.spacing_mut().item_spacing.y = space::XXS; egui::ScrollArea::vertical() .id_salt(id) .max_height(max_height) @@ -116,52 +271,111 @@ pub fn log_view(ui: &mut Ui, id: impl std::hash::Hash, lines: &[String], max_hei .show(ui, |ui| { ui.set_min_width(ui.available_width()); if lines.is_empty() { - ui.label(RichText::new("Nothing yet.").monospace().color(P.faint)); + ui.label( + RichText::new("Nothing logged yet.") + .font(theme::mono(text::MONO)) + .color(P.text_tertiary), + ); } for line in lines { - ui.label(RichText::new(line).monospace().color(P.muted)); + ui.add( + Label::new( + RichText::new(line) + .font(theme::mono(text::MONO)) + .color(P.text_secondary), + ) + .wrap(), + ); } }); }); } -/// A card that opens and closes from its title row. `id` is global to the -/// context, not to the enclosing `Ui`; use distinct ids within an app. -pub fn collapsible( +/// A row that opens and closes the content under it, with a painted +/// chevron. `id` is global to the context; use distinct ids. +pub fn disclosure( ui: &mut Ui, id: impl std::hash::Hash, title: &str, default_open: bool, body: impl FnOnce(&mut Ui) -> R, ) -> Option { - let id = collapsible_id(id); - card(ui, |ui| { - let state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open); - let (_, _, body) = state - .show_header(ui, |ui| { - ui.label( - RichText::new(title) - .text_style(theme::title()) - .color(P.text), - ); - }) - .body_unindented(|ui| { - ui.add_space(8.0); - body(ui) - }); - body.map(|b| b.inner) - }) - .inner + let id = Id::new("brolink.disclosure").with(id); + let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open); + let galley = ui.painter().layout_no_wrap( + title.to_owned(), + theme::medium(text::BODY), + Color32::PLACEHOLDER, + ); + let h = size::CONTROL; + let (rect, response) = ui.allocate_exact_size( + Vec2::new(ui.available_width().max(galley.size().x + 24.0), h), + Sense::click(), + ); + keep_in_view(&response); + if response.clicked() { + state.toggle(ui); + } + let open = state.is_open(); + response.widget_info(|| { + WidgetInfo::selected(WidgetType::CollapsingHeader, ui.is_enabled(), open, title) + }); + if ui.is_rect_visible(rect) { + let hot = response.hovered() || response.has_focus(); + let color = if hot { P.text } else { P.text_secondary }; + paint_icon( + ui.painter(), + Pos2::new(rect.left() + 6.0, rect.center().y), + if open { + Icon::ChevronDown + } else { + Icon::ChevronRight + }, + color, + ); + ui.painter().galley( + Pos2::new(rect.left() + 20.0, rect.center().y - galley.size().y / 2.0), + galley, + color, + ); + focus_ring(ui, &response, radius::SM); + } + let out = state.show_body_unindented(ui, |ui| { + ui.add_space(space::XS); + body(ui) + }); + out.map(|r| r.inner) } -fn collapsible_id(id: impl std::hash::Hash) -> Id { - Id::new("brolink.collapsible").with(id) +/// A [`card`] whose content folds away under a [`disclosure`] row. +pub fn collapsible( + ui: &mut Ui, + id: impl std::hash::Hash, + title: &str, + default_open: bool, + body: impl FnOnce(&mut Ui) -> R, +) -> Option { + Frame::new() + .fill(P.surface) + .stroke(stroke(1.0, P.hairline)) + .corner_radius(CornerRadius::same(radius::MD)) + .inner_margin(margin(space::MD, space::XS)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + let r = disclosure(ui, id, title, default_open, body); + if r.is_some() { + ui.add_space(space::SM); + } + r + }) + .inner } -/// Centre the content in a column no wider than `max_width`. +/// Centre the content in a column no wider than `max_width`, with at +/// least the page gutter on each side. pub fn content_column(ui: &mut Ui, max_width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { let avail = ui.available_width(); - let w = (avail - 40.0).max(0.0).min(max_width); + let w = (avail - 2.0 * space::XL).max(0.0).min(max_width); let pad = ((avail - w) / 2.0).max(0.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; @@ -175,133 +389,394 @@ pub fn content_column(ui: &mut Ui, max_width: f32, add: impl FnOnce(&mut Ui) .inner } -/// The bar across the top of a window. +/// The bar across the top of the window: brand, tabs, status. pub fn top_bar(ctx: &egui::Context, id: impl Into, add: impl FnOnce(&mut Ui) -> R) -> R { egui::TopBottomPanel::top(id) - .frame( - Frame::new() - .fill(P.bg) - .inner_margin(Margin::symmetric(20, 12)), - ) + .frame(Frame::new().fill(P.bg).inner_margin(margin(space::LG, 0.0))) + .exact_height(size::TOP_BAR) .show_separator_line(true) - .show(ctx, add) + .show(ctx, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = space::SM; + add(ui) + }) + .inner + }) .inner } -/// The bar across the bottom of a window: version, ports, other small print. +/// The bar across the bottom of the window: version, account, small print. pub fn bottom_bar(ctx: &egui::Context, id: impl Into, add: impl FnOnce(&mut Ui) -> R) -> R { egui::TopBottomPanel::bottom(id) - .frame( - Frame::new() - .fill(P.bg) - .inner_margin(Margin::symmetric(20, 8)), - ) + .frame(Frame::new().fill(P.bg).inner_margin(margin(space::LG, 6.0))) .show_separator_line(true) .show(ctx, |ui| { - ui.style_mut().override_text_style = Some(theme::caption()); - ui.visuals_mut().override_text_color = Some(P.faint); + ui.style_mut().override_font_id = Some(theme::mono(text::LABEL + 0.5)); + ui.visuals_mut().override_text_color = Some(P.text_tertiary); add(ui) }) .inner } -/// The translucent frame the stream overlay sits in. +/// The frame of anything that floats over the stream picture: toasts, +/// questions, the toolbar's menus. pub fn overlay_frame() -> Frame { Frame::new() - .fill(Color32::from_black_alpha(196)) - .stroke(stroke(1.0, Color32::from_white_alpha(26))) - .corner_radius(CornerRadius::same(10)) - .inner_margin(Margin::symmetric(12, 8)) + .fill(P.overlay) + .stroke(stroke(1.0, Color32::from_white_alpha(22))) + .corner_radius(CornerRadius::same(radius::LG)) + .inner_margin(margin(space::MD, space::MD)) + .shadow(theme::elevation::OVERLAY) +} + +/// The frame of a panel over the stream (stream settings, performance). +pub fn panel_frame() -> Frame { + Frame::new() + .fill(P.surface) + .stroke(stroke(1.0, P.border)) + .corner_radius(CornerRadius::same(radius::LG)) + .inner_margin(Margin::same(space::LG as i8)) + .shadow(theme::elevation::OVERLAY) +} + +/// A floating panel's title row with a close button. Returns true when +/// the close button was pressed. +pub fn panel_header(ui: &mut Ui, title: &str) -> bool { + let mut close = false; + ui.horizontal(|ui| { + ui.label( + RichText::new(title) + .font(theme::semibold(text::TITLE)) + .color(P.text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + close = icon_button(ui, Icon::Close, &format!("Close {title}")).clicked(); + }); + }); + ui.add_space(space::XS); + close } // --------------------------------------------------------------------------- // Text // --------------------------------------------------------------------------- -/// Secondary text: hints, help, metadata. +/// Secondary text under a label: hints, help, metadata. Wraps. pub fn caption(ui: &mut Ui, text: impl Into) -> Response { - ui.label( - RichText::new(text) - .text_style(theme::caption()) - .color(P.muted), + ui.add( + Label::new( + RichText::new(text) + .text_style(theme::caption()) + .color(P.text_secondary), + ) + .wrap(), ) } -/// Body text in the muted colour. +/// Small print: the quietest text that still reads at AA. +pub fn small_print(ui: &mut Ui, text: impl Into) -> Response { + ui.add( + Label::new( + RichText::new(text) + .text_style(theme::caption()) + .color(P.text_tertiary), + ) + .wrap(), + ) +} + +/// Body text in the secondary colour. Wraps. pub fn muted(ui: &mut Ui, text: impl Into) -> Response { - ui.label(RichText::new(text).color(P.muted)) + ui.add(Label::new(RichText::new(text).color(P.text_secondary)).wrap()) } -/// Slightly heavier body text, for the name in a list row. +/// Medium-weight body text, for a name in a row. pub fn strong(ui: &mut Ui, text: impl Into) -> Response { - ui.label(RichText::new(text).font(theme::medium(14.0)).color(P.text)) + ui.label( + RichText::new(text) + .font(theme::medium(text::BODY)) + .color(P.text), + ) } -/// A large, letter-spaced code such as a pairing PIN. -pub fn display_digits(ui: &mut Ui, digits: &str) -> Response { +/// Mono data: an address, a version, a rate. +pub fn mono(ui: &mut Ui, text: impl Into) -> Response { ui.label( - RichText::new(digits) - .text_style(theme::display()) - .extra_letter_spacing(6.0) - .color(P.accent), + RichText::new(text) + .font(theme::mono(text::MONO)) + .color(P.text_secondary), ) } -/// Vertically centred, muted text for an empty list. Wraps: a line that -/// ran past the card would widen everything laid out after it. +/// A code such as the pairing PIN, one digit to a box. +pub fn display_digits(ui: &mut Ui, digits: &str) -> Response { + let font = theme::mono_medium(text::DISPLAY); + let cell = Vec2::new(44.0, 56.0); + let n = digits.chars().count().max(1) as f32; + let total = Vec2::new(n * cell.x + (n - 1.0) * space::SM, cell.y); + let (rect, response) = ui.allocate_exact_size(total, Sense::hover()); + response.widget_info(|| { + let spoken: Vec = digits.chars().map(String::from).collect(); + WidgetInfo::labeled(WidgetType::Label, true, format!("PIN {}", spoken.join(" "))) + }); + if ui.is_rect_visible(rect) { + for (i, ch) in digits.chars().enumerate() { + let min = rect.min + Vec2::new(i as f32 * (cell.x + space::SM), 0.0); + let r = Rect::from_min_size(min, cell); + ui.painter().rect( + r, + CornerRadius::same(radius::SM), + P.raised, + stroke(1.0, P.border_strong), + StrokeKind::Inside, + ); + ui.painter() + .text(r.center(), Align2::CENTER_CENTER, ch, font.clone(), P.text); + } + } + response +} + +/// Text for a list with nothing in it yet, with a spinner while something +/// is being looked for. Wraps: a line that ran past the card would widen +/// everything laid out after it. pub fn empty_state(ui: &mut Ui, text: &str, busy: bool) { ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = space::SM; if busy { - ui.add(spinner(14.0, P.faint)); + ui.add(spinner(14.0, P.text_tertiary)); } - ui.label(RichText::new(text).color(P.muted)); + ui.label(RichText::new(text).color(P.text_secondary)); }); } -/// An inline message tinted by tone. Errors, warnings, confirmations. -pub fn notice(ui: &mut Ui, tone: Tone, text: &str) -> Response { - let color = tone.color(); +/// A dot in a fixed gutter and wrapped text beside it: every line of the +/// text starts at the same x, however many there are. +fn dotted(ui: &mut Ui, tone: Tone, add: impl FnOnce(&mut Ui) -> R) -> R { + let gutter = 16.0; + let avail = ui.available_rect_before_wrap(); + let text_rect = Rect::from_min_max( + Pos2::new(avail.left() + gutter, avail.top()), + Pos2::new(avail.right(), avail.bottom()), + ); + let mut child = ui.new_child( + UiBuilder::new() + .max_rect(text_rect) + .layout(Layout::top_down(Align::Min)), + ); + let r = add(&mut child); + let used = child.min_rect(); + // The dot sits on the first line's centre. + let first_line = ui.text_style_height(&egui::TextStyle::Body); + let dot_c = Pos2::new( + avail.left() + size::DOT / 2.0, + avail.top() + first_line / 2.0, + ); + ui.painter() + .circle_filled(dot_c, size::DOT / 2.0 - 0.5, tone.color()); + ui.allocate_rect( + Rect::from_min_max(avail.min, Pos2::new(avail.right(), used.bottom())), + Sense::hover(), + ); + r +} + +fn notice_frame(tone: Tone) -> Frame { Frame::new() - .fill(color.gamma_multiply(0.12)) - .stroke(stroke(1.0, color.gamma_multiply(0.5))) - .corner_radius(CornerRadius::same(RADIUS)) - .inner_margin(Margin::symmetric(12, 9)) + .fill(tone.wash()) + .stroke(stroke(1.0, tone.line())) + .corner_radius(CornerRadius::same(radius::MD)) + .inner_margin(margin(space::MD, space::MD - 2.0)) +} + +/// An inline message: errors, warnings, confirmations. The wash and outline +/// carry the tone; the text stays in the primary colour. +pub fn notice(ui: &mut Ui, tone: Tone, text: &str) -> Response { + notice_frame(tone) .show(ui, |ui| { ui.set_min_width(ui.available_width()); - ui.horizontal_wrapped(|ui| { - ui.label(RichText::new("●").size(9.0).color(color)); - ui.label(RichText::new(text).color(P.text)); + dotted(ui, tone, |ui| { + ui.add(Label::new(RichText::new(text).color(P.text)).wrap()); }); }) .response } +/// A [`notice`] with a title, a body and a row of actions under them. +pub fn banner( + ui: &mut Ui, + tone: Tone, + title: &str, + body: Option<&str>, + actions: impl FnOnce(&mut Ui), +) -> Response { + notice_frame(tone) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + dotted(ui, tone, |ui| { + ui.spacing_mut().item_spacing.y = space::XS; + ui.add( + Label::new( + RichText::new(title) + .font(theme::medium(text::BODY)) + .color(P.text), + ) + .wrap(), + ); + if let Some(b) = body { + muted(ui, b); + } + action_row(ui, space::XS, actions); + }); + }) + .response +} + +/// A row of actions under a block of text, lined up with the text: a +/// filled or outlined button's edge starts the line, and a quiet button +/// (text until hovered) is pulled back by its padding so its label does. +/// Takes no room at all when `add` adds nothing; otherwise `gap` goes +/// above it. +pub fn action_row(ui: &mut Ui, gap: f32, add: impl FnOnce(&mut Ui)) { + let top = ui.available_rect_before_wrap(); + // One control high, as `horizontal_wrapped` starts: its lines centre + // in that height and further lines go below. + let line = Rect::from_min_size( + top.min + Vec2::new(0.0, gap), + Vec2::new(top.width(), ui.spacing().interact_size.y), + ); + let mut child = ui.new_child( + UiBuilder::new() + .max_rect(line) + .layout(Layout::left_to_right(Align::Center).with_main_wrap(true)), + ); + child.spacing_mut().item_spacing.x = space::SM; + let key = flush_key(child.id()); + child.data_mut(|d| d.insert_temp(key, true)); + add(&mut child); + child.data_mut(|d| d.remove::(key)); + let used = child.min_rect(); + if used.width() > 0.0 && used.height() > 0.0 { + ui.allocate_rect( + Rect::from_min_max(top.min, Pos2::new(top.right(), used.bottom())), + Sense::hover(), + ); + } +} + +fn flush_key(row: Id) -> Id { + Id::new("brolink.action_row").with(row) +} + +/// Whether the next widget starts a line of an [`action_row`]. +fn starts_action_line(ui: &Ui) -> bool { + ui.data(|d| d.get_temp::(flush_key(ui.id()))) + .unwrap_or(false) + && ui.cursor().min.x <= ui.max_rect().min.x + 0.5 +} + // --------------------------------------------------------------------------- // Key / value // --------------------------------------------------------------------------- +/// One line of a [`kv_grid`]. +pub struct Kv<'a> { + pub key: &'a str, + pub value: String, + /// A status dot before the value. + pub tone: Option, + /// Set the value in mono, for figures that should line up. + pub mono: bool, +} + +impl<'a> Kv<'a> { + pub fn new(key: &'a str, value: impl Into) -> Self { + Self { + key, + value: value.into(), + tone: None, + mono: false, + } + } + + pub fn mono(mut self) -> Self { + self.mono = true; + self + } + + pub fn tone(mut self, tone: Tone) -> Self { + self.tone = Some(tone); + self + } +} + /// Aligned key/value rows. A value wraps inside the card: an `egui::Grid` /// extends instead, and one row wider than the card widens every widget -/// laid out after it (the card, the rows below, the toggles off-screen). -pub fn kv_grid(ui: &mut Ui, rows: &[(&str, String)]) { - let font = egui::TextStyle::Body.resolve(ui.style()); +/// laid out after it. Below 300 points the key goes above its value. +pub fn kv_grid(ui: &mut Ui, rows: &[Kv<'_>]) { + let font = theme::regular(text::CAPTION); let key_w = rows .iter() - .map(|(k, _)| { + .map(|kv| { ui.painter() - .layout_no_wrap((*k).to_owned(), font.clone(), Color32::PLACEHOLDER) + .layout_no_wrap(kv.key.to_owned(), font.clone(), Color32::PLACEHOLDER) .size() .x }) - .fold(0.0_f32, f32::max); + .fold(0.0_f32, f32::max) + .min(ui.available_width() * 0.4); + let stacked = ui.available_width() < 300.0; ui.vertical(|ui| { - ui.spacing_mut().item_spacing = Vec2::new(18.0, 6.0); - for (k, v) in rows { - ui.horizontal_top(|ui| { - let key = ui.label(RichText::new(*k).color(P.muted)); - ui.add_space((key_w - key.rect.width()).max(0.0)); - ui.add(Label::new(RichText::new(v).color(P.text)).wrap()); - }); + ui.spacing_mut().item_spacing = Vec2::new(space::LG, space::SM); + for kv in rows { + let rich = || { + let t = RichText::new(&kv.value).color(P.text); + if kv.mono { + t.font(theme::mono(text::MONO)) + } else { + t + } + }; + let value = |ui: &mut Ui| { + if let Some(t) = kv.tone { + dotted(ui, t, |ui| { + ui.add(Label::new(rich()).wrap()); + }); + } else { + ui.add(Label::new(rich()).wrap()); + } + }; + if stacked { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = space::XXS; + ui.label( + RichText::new(kv.key) + .font(font.clone()) + .color(P.text_tertiary), + ); + value(ui); + }); + } else { + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout( + Vec2::new(key_w, 18.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(key_w); + ui.add_space(1.5); + ui.add( + Label::new( + RichText::new(kv.key) + .font(font.clone()) + .color(P.text_tertiary), + ) + .truncate(), + ); + }, + ); + ui.vertical(value); + }); + } } }); } @@ -310,140 +785,609 @@ pub fn kv_grid(ui: &mut Ui, rows: &[(&str, String)]) { // Buttons // --------------------------------------------------------------------------- -fn text_button( +/// The five kinds of button. One `Primary` per screen at most. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// The screen's main action: white, like the site's primary button. + Primary, + /// A real action that is not the main one: outlined. + Secondary, + /// Everything else: text until hovered. + Quiet, + /// A quiet button that ends or removes something: red text. + Danger, + /// The confirming step of a destructive action: red fill. + Destructive, +} + +struct Look { + fill: Color32, + line: Color32, + ink: Color32, +} + +fn look(kind: Kind, enabled: bool, hovered: bool, pressed: bool) -> Look { + let none = Color32::TRANSPARENT; + if !enabled { + return match kind { + Kind::Primary | Kind::Destructive => Look { + fill: P.raised, + line: P.border, + ink: P.text_disabled, + }, + Kind::Secondary => Look { + fill: none, + line: P.hairline, + ink: P.text_disabled, + }, + Kind::Quiet | Kind::Danger => Look { + fill: none, + line: none, + ink: P.text_disabled, + }, + }; + } + match kind { + Kind::Primary => Look { + fill: if pressed { + P.primary_pressed + } else if hovered { + P.primary_hover + } else { + P.primary + }, + line: none, + ink: P.on_primary, + }, + Kind::Destructive => Look { + fill: if pressed || hovered { + lerp_color(P.danger_fill, Color32::BLACK, 0.12) + } else { + P.danger_fill + }, + line: none, + ink: Color32::WHITE, + }, + Kind::Secondary => Look { + fill: if pressed { + P.pressed + } else if hovered { + P.raised_hover + } else { + P.raised + }, + line: if hovered { P.border_strong } else { P.border }, + ink: P.text, + }, + Kind::Quiet => Look { + fill: if pressed { + P.pressed + } else if hovered { + P.raised_hover + } else { + none + }, + line: none, + ink: if hovered || pressed { + P.text + } else { + P.text_secondary + }, + }, + Kind::Danger => Look { + fill: if pressed || hovered { + P.danger.gamma_multiply(0.14) + } else { + none + }, + line: none, + ink: P.danger, + }, + } +} + +/// A text button of any [`Kind`], at the height of the surrounding +/// `interact_size` (32 points, 28 in the stream toolbar). +pub fn button(ui: &mut Ui, kind: Kind, label: &str) -> Response { + labelled_button(ui, kind, label, None, label) +} + +fn labelled_button( ui: &mut Ui, + kind: Kind, label: &str, - font: egui::FontId, - pad: Vec2, - paint: impl FnOnce(&Ui, &Response, egui::Rect) -> Color32, + trailing: Option, + a11y: &str, ) -> Response { + let font = theme::medium(if ui.spacing().interact_size.y < size::CONTROL { + text::CAPTION + 0.5 + } else { + text::BODY + }); let galley = ui .painter() .layout_no_wrap(label.to_owned(), font, Color32::PLACEHOLDER); - let mut size = galley.size() + 2.0 * pad; - size.y = size.y.max(ui.spacing().interact_size.y); - let (rect, response) = ui.allocate_exact_size(size, Sense::click()); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), label)); + let h = ui.spacing().interact_size.y; + let pad = space::MD; + let icon_w = if trailing.is_some() { 16.0 } else { 0.0 }; + let w = (galley.size().x + 2.0 * pad + icon_w).max(h); + if matches!(kind, Kind::Quiet | Kind::Danger) && starts_action_line(ui) { + // No fill or outline marks a quiet button's box, so its label is + // what the eye lines up: pull it back by the padding. + ui.add_space(-pad); + } + let (rect, response) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + keep_in_view(&response); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), a11y)); if ui.is_rect_visible(rect) { - let fg = paint(ui, &response, rect); - let pos = rect.center() - galley.size() / 2.0; - ui.painter().galley(pos, galley, fg); - focus_ring(ui, &response, RADIUS); + let l = look( + kind, + ui.is_enabled(), + response.hovered() || response.has_focus(), + response.is_pointer_button_down_on(), + ); + ui.painter().rect( + rect, + CornerRadius::same(radius::SM), + l.fill, + stroke(1.0, l.line), + StrokeKind::Inside, + ); + let text_x = rect.left() + pad + (w - 2.0 * pad - icon_w - galley.size().x) / 2.0; + let pos = Pos2::new(text_x, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley.clone(), l.ink); + if let Some(icon) = trailing { + paint_icon( + ui.painter(), + Pos2::new(pos.x + galley.size().x + 10.0, rect.center().y), + icon, + l.ink, + ); + } + focus_ring(ui, &response, radius::SM); } response } -/// Keep keyboard navigation visible for controls with custom painting. -fn focus_ring(ui: &Ui, response: &Response, radius: u8) { - if response.has_focus() && ui.is_enabled() { - ui.painter().rect_stroke( - response.rect.shrink(2.0), - CornerRadius::same(radius.saturating_sub(2)), - stroke(2.0, P.text), +/// The one white button on a screen. +pub fn primary_button(ui: &mut Ui, label: &str) -> Response { + button(ui, Kind::Primary, label) +} + +/// An outlined button for a real but secondary action. +pub fn secondary_button(ui: &mut Ui, label: &str) -> Response { + button(ui, Kind::Secondary, label) +} + +/// A quiet button: text only until hovered. +pub fn ghost_button(ui: &mut Ui, label: &str) -> Response { + button(ui, Kind::Quiet, label) +} + +/// A quiet button that ends or removes something. +pub fn danger_button(ui: &mut Ui, label: &str) -> Response { + button(ui, Kind::Danger, label) +} + +/// The red, filled confirming step of a destructive action. +pub fn destructive_button(ui: &mut Ui, label: &str) -> Response { + button(ui, Kind::Destructive, label) +} + +/// A square quiet button showing only `icon`; `label` is what a screen +/// reader says and the tooltip shows. +pub fn icon_button(ui: &mut Ui, icon: Icon, label: &str) -> Response { + let s = ui.spacing().interact_size.y; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(s), Sense::click()); + keep_in_view(&response); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), label)); + if ui.is_rect_visible(rect) { + let l = look( + Kind::Quiet, + ui.is_enabled(), + response.hovered() || response.has_focus(), + response.is_pointer_button_down_on(), + ); + ui.painter().rect( + rect, + CornerRadius::same(radius::SM), + l.fill, + Stroke::NONE, StrokeKind::Inside, ); + paint_icon(ui.painter(), rect.center(), icon, l.ink); + focus_ring(ui, &response, radius::SM); } + response.on_hover_text(label) } -/// The one accent-filled button on a screen. -pub fn primary_button(ui: &mut Ui, label: &str) -> Response { - filled_button(ui, label, P.accent_fill, P.on_accent) +/// A link: primary text with a hairline underline that brightens on hover. +pub fn link(ui: &mut Ui, label: &str) -> Response { + let font = theme::regular(text::CAPTION); + let galley = ui + .painter() + .layout_no_wrap(label.to_owned(), font, Color32::PLACEHOLDER); + let size = Vec2::new(galley.size().x, galley.size().y.max(size::HIT_MIN)); + let (rect, response) = ui.allocate_exact_size(size, Sense::click()); + keep_in_view(&response); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Link, ui.is_enabled(), label)); + if ui.is_rect_visible(rect) { + let hot = response.hovered() || response.has_focus(); + let top = rect.center().y - galley.size().y / 2.0; + let base = top + galley.size().y + 1.0; + let w = galley.size().x; + ui.painter() + .galley(Pos2::new(rect.left(), top), galley, P.text); + ui.painter().hline( + rect.left()..=rect.left() + w, + base, + stroke(1.0, if hot { P.text } else { P.border_strong }), + ); + focus_ring(ui, &response, radius::SM); + } + response } -/// A filled button in a tone other than the accent: red for the confirm -/// step of a destructive action. -pub fn toned_button(ui: &mut Ui, label: &str, tone: Tone) -> Response { - filled_button(ui, label, tone.color(), P.text) +// --------------------------------------------------------------------------- +// Menus +// --------------------------------------------------------------------------- + +/// Where a popup hung from a control goes, and how tall it may be. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Drop { + /// The corner of the popup that touches the control's edge. + pub pos: Pos2, + pub pivot: Align2, + /// The popup's outer height may not exceed this; anything longer + /// scrolls inside it. + pub max_height: f32, + /// True when it opens upwards, over the control. + pub above: bool, } -fn filled_button(ui: &mut Ui, label: &str, color: Color32, fg: Color32) -> Response { - let pad = ui.spacing().button_padding + Vec2::new(4.0, 1.0); - text_button(ui, label, theme::medium(14.0), pad, |ui, r, rect| { - let enabled = ui.is_enabled(); - let (fill, border, text) = if !enabled { - (P.raised, P.border_strong.gamma_multiply(0.6), P.faint) - } else if r.is_pointer_button_down_on() { - ( - lerp_color(color, Color32::BLACK, 0.12), - Color32::TRANSPARENT, - fg, - ) - } else if r.hovered() { - ( - lerp_color(color, Color32::BLACK, 0.10), - Color32::TRANSPARENT, - fg, - ) +/// Place a popup of outer height `wanted` against `anchor` inside +/// `screen`. It opens below when it fits there, and otherwise on whichever +/// side has more room (a machine's menu near the bottom of a short window +/// opens upwards). Its height is capped at that side's room, less a margin +/// from the window's edge, so no part of it is ever off screen: what does +/// not fit scrolls. `right` hangs it from the control's right edge, for +/// controls on the right of the window. +pub fn place_popup(screen: Rect, anchor: Rect, wanted: f32, right: bool) -> Drop { + let gap = space::XS; + let edge = space::SM; + let below = (screen.bottom() - edge - (anchor.bottom() + gap)).max(0.0); + let above = (anchor.top() - gap - (screen.top() + edge)).max(0.0); + let x = if right { anchor.right() } else { anchor.left() }; + if wanted <= below || below >= above { + Drop { + pos: Pos2::new(x, anchor.bottom() + gap), + pivot: if right { + Align2::RIGHT_TOP + } else { + Align2::LEFT_TOP + }, + max_height: below, + above: false, + } + } else { + Drop { + pos: Pos2::new(x, anchor.top() - gap), + pivot: if right { + Align2::RIGHT_BOTTOM + } else { + Align2::LEFT_BOTTOM + }, + max_height: above, + above: true, + } + } +} + +/// The height a scrolling region inside a panel may take so the panel +/// ends a margin above the window's bottom edge: from the cursor down, +/// less `below` for what the panel still has to lay out under it (its +/// footer and bottom margin). Never less than one control, so a tiny +/// window still shows something to scroll. +pub fn room_below(ui: &Ui, below: f32) -> f32 { + let bottom = ui.ctx().screen_rect().bottom() - space::SM; + (bottom - ui.cursor().top() - below).max(size::CONTROL) +} + +/// A vertical scroll area for content that may be cut off by the window: +/// its scroll bar shows at rest, so a clipped list reads as one. Keyboard +/// focus moving to a control inside scrolls it into view (see +/// [`keep_in_view`]). +pub fn clipped_scroll( + ui: &mut Ui, + id: impl std::hash::Hash, + max_height: f32, + add: impl FnOnce(&mut Ui) -> R, +) -> egui::scroll_area::ScrollAreaOutput { + let saved = ui.spacing().scroll; + ui.spacing_mut().scroll = theme::clipped_scroll(); + let out = egui::ScrollArea::vertical() + .id_salt(id) + .max_height(max_height) + // A popup or window lays out in the size it had last frame; without + // this the list could never grow past its first, smaller, height. + .min_scrolled_height(max_height) + .show(ui, |ui| { + ui.spacing_mut().scroll = saved; + add(ui) + }); + ui.spacing_mut().scroll = saved; + out +} + +/// When keyboard focus arrives at a control, scroll it into view: egui +/// moves focus to a control clipped away in a scroll area without showing +/// it, and a menu or panel cut short by a small window would otherwise take +/// focus somewhere nobody can see. +pub fn keep_in_view(response: &Response) { + if response.gained_focus() { + response.scroll_to_me_animation(None, egui::style::ScrollAnimation::none()); + } +} + +/// Where the menu open now sits on screen, or the visible part of an open +/// [`select`]'s list, for layout checks. +pub fn open_menu_rect(ctx: &egui::Context) -> Option { + let (id, rect): (Id, Rect) = ctx.data(|d| d.get_temp(open_menu_key()))?; + ctx.memory(|m| m.is_popup_open(id)).then_some(rect) +} + +fn open_menu_key() -> Id { + Id::new("brolink.open_menu") +} + +/// Open a popup menu under `trigger` while it is toggled on. A real egui +/// popup: `any_popup_open` is true while it shows, Escape and a click +/// outside close it. It hangs from the trigger's nearer edge, so a menu at +/// the right of the window opens leftwards instead of running off it, and +/// it never runs past the window's top or bottom: it opens upwards when +/// there is more room above, and scrolls when neither side has room for +/// all of it (see [`place_popup`]). +fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R) -> Option { + let id = trigger.id.with("menu"); + if trigger.clicked() { + ui.memory_mut(|m| m.toggle_popup(id)); + } + if !ui.memory(|m| m.is_popup_open(id)) { + return None; + } + let ctx = ui.ctx().clone(); + let screen = ctx.screen_rect(); + let right = trigger.rect.center().x > screen.center().x; + // The menu's full height, measured when it was last laid out. + let key = id.with("height"); + let known: Option = ctx.data(|d| d.get_temp(key)); + let drop = place_popup(screen, trigger.rect, known.unwrap_or(0.0), right); + let style = ui.style().clone(); + let chrome = style.spacing.menu_margin.sum().y + 2.0 * style.visuals.window_stroke.width; + let shown = egui::Area::new(id) + .kind(egui::UiKind::Popup) + .order(egui::Order::Foreground) + .fixed_pos(drop.pos) + .pivot(drop.pivot) + .constrain(true) + .show(&ctx, |ui| { + Frame::popup(&style) + .show(ui, |ui| { + ui.set_min_width(200.0); + ui.set_max_width(320.0); + let max = (drop.max_height - chrome).max(size::CONTROL_SM); + clipped_scroll(ui, "brolink.menu", max, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().interact_size.y = size::CONTROL_SM; + ui.with_layout(Layout::top_down_justified(Align::Min), add) + .inner + }) + }) + .inner + }); + let full = shown.inner.content_size.y + chrome; + if known.is_none_or(|k| (k - full).abs() > 0.5) { + ctx.data_mut(|d| d.insert_temp(key, full)); + if place_popup(screen, trigger.rect, full, right) != drop { + ctx.request_discard("menu moved to fit the window"); + } + } + ctx.data_mut(|d| d.insert_temp(open_menu_key(), (id, shown.response.rect))); + let outside = trigger.clicked_elsewhere() && shown.response.clicked_elsewhere(); + if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.memory_mut(|m| m.close_popup()); + } + Some(shown.inner.inner) +} + +/// A quiet button with a chevron that opens a menu. +pub fn menu_button(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> Option { + let trigger = labelled_button(ui, Kind::Quiet, label, Some(Icon::ChevronDown), label); + popup_menu(ui, &trigger, add) +} + +/// An icon button that opens a menu; `label` is its accessible name. +pub fn icon_menu( + ui: &mut Ui, + icon: Icon, + label: &str, + add: impl FnOnce(&mut Ui) -> R, +) -> Option { + let trigger = icon_button(ui, icon, label); + popup_menu(ui, &trigger, add) +} + +fn menu_row(ui: &mut Ui, label: &str, checked: Option, enabled: bool) -> Response { + let font = theme::regular(text::BODY - 0.5); + // The check column is always there, so every item's text lines up. + let gutter = 26.0; + let wrap = ui.available_width() - gutter - space::SM; + let galley = ui + .painter() + .layout(label.to_owned(), font, Color32::PLACEHOLDER, wrap); + let h = (galley.size().y + 10.0).max(size::CONTROL_SM); + let w = ui.available_width(); + let sense = if enabled { + Sense::click() + } else { + Sense::hover() + }; + let (rect, response) = ui.allocate_exact_size(Vec2::new(w, h), sense); + keep_in_view(&response); + response.widget_info(|| match checked { + Some(on) => WidgetInfo::selected(WidgetType::RadioButton, enabled, on, label), + None => WidgetInfo::labeled(WidgetType::Button, enabled, label), + }); + if ui.is_rect_visible(rect) { + let hot = enabled && (response.hovered() || response.has_focus()); + if hot { + ui.painter() + .rect_filled(rect, CornerRadius::same(radius::SM), P.raised_hover); + } + let ink = if !enabled { + P.text_disabled + } else if hot { + P.text } else { - (color, Color32::TRANSPARENT, fg) + P.text_secondary }; - ui.painter().rect( - rect, - CornerRadius::same(RADIUS), - fill, - stroke(1.0, border), - StrokeKind::Inside, + if checked == Some(true) { + paint_icon( + ui.painter(), + Pos2::new(rect.left() + 12.0, rect.top() + h.min(28.0) / 2.0), + Icon::Check, + P.text, + ); + } + ui.painter().galley( + Pos2::new( + rect.left() + gutter, + rect.center().y - galley.size().y / 2.0, + ), + galley, + ink, ); - text - }) + } + if response.clicked() { + ui.memory_mut(|m| m.close_popup()); + } + response +} + +/// An item in a menu. Closes the menu when clicked. +pub fn menu_item(ui: &mut Ui, label: &str) -> Response { + menu_row(ui, label, None, true) +} + +/// A menu item that can be greyed out. +pub fn menu_item_enabled(ui: &mut Ui, enabled: bool, label: &str) -> Response { + menu_row(ui, label, None, enabled) } -/// A menu button with a painted chevron; the bundled fonts carry no -/// triangle glyph. -pub fn menu_button( - ui: &mut Ui, - label: &str, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - let r = ui.menu_button(format!("{label} "), add_contents); - let rect = r.response.rect; - let c = egui::pos2(rect.right() - 13.0, rect.center().y - 1.0); - let col = ui.style().interact(&r.response).fg_stroke.color; - let s = stroke(1.5, col); - ui.painter().line_segment( - [egui::pos2(c.x - 4.0, c.y - 2.0), egui::pos2(c.x, c.y + 2.0)], - s, - ); - ui.painter().line_segment( - [egui::pos2(c.x, c.y + 2.0), egui::pos2(c.x + 4.0, c.y - 2.0)], - s, - ); - r.inner +/// A menu item with a check mark when chosen. +pub fn menu_choice(ui: &mut Ui, label: &str, chosen: bool) -> Response { + menu_row(ui, label, Some(chosen), true) } -/// A quiet button: text only until hovered. -pub fn ghost_button(ui: &mut Ui, label: &str) -> Response { - ghost(ui, label, P.muted, P.text) +/// A heading inside a menu. +pub fn menu_label(ui: &mut Ui, label: &str) { + ui.add_space(space::XS + 2.0); + ui.horizontal(|ui| { + ui.add_space(26.0); + section_label(ui, label); + }); + ui.add_space(space::XS); } -/// A quiet button for a destructive action; turns red on hover. -pub fn danger_button(ui: &mut Ui, label: &str) -> Response { - ghost(ui, label, P.muted, P.danger) +/// A line of help at the foot of a menu. +pub fn menu_note(ui: &mut Ui, note: &str) { + ui.add_space(space::XS); + Frame::new() + .inner_margin(Margin { + left: 26, + right: space::SM as i8, + top: space::XS as i8, + bottom: space::XS as i8, + }) + .show(ui, |ui| { + small_print(ui, note); + }); } -fn ghost(ui: &mut Ui, label: &str, idle: Color32, hot: Color32) -> Response { - let pad = Vec2::new(10.0, 6.0); - text_button(ui, label, theme::medium(13.5), pad, |ui, r, rect| { - let enabled = ui.is_enabled(); - if enabled && (r.hovered() || r.is_pointer_button_down_on()) { - ui.painter().rect( - rect, - CornerRadius::same(RADIUS), - P.raised_hover, - Stroke::NONE, - StrokeKind::Inside, - ); - hot - } else if enabled { - idle - } else { - P.faint +/// A hairline between groups of menu items. +pub fn menu_separator(ui: &mut Ui) { + ui.add_space(space::XS); + let w = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(Vec2::new(w, 1.0), Sense::hover()); + ui.painter() + .hline(rect.x_range(), rect.center().y, stroke(1.0, P.hairline)); + ui.add_space(space::XS); +} + +// --------------------------------------------------------------------------- +// Choices +// --------------------------------------------------------------------------- + +/// Tabs in the top bar: text, with a rule under the chosen one sitting on +/// the bar's bottom hairline. Each option carries a tooltip (its keyboard +/// shortcut, say); empty for none. Returns true when the choice changed. +pub fn tabs( + ui: &mut Ui, + options: &[(T, &str, String)], + selected: &mut T, +) -> bool { + let mut changed = false; + let h = ui.available_height().max(size::CONTROL); + ui.spacing_mut().item_spacing.x = 0.0; + for (value, label, tip) in options { + let on = *selected == *value; + let galley = ui.painter().layout_no_wrap( + (*label).to_owned(), + theme::medium(text::BODY - 0.5), + Color32::PLACEHOLDER, + ); + let w = galley.size().x + 2.0 * space::MD; + let (rect, mut response) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + response.widget_info(|| { + WidgetInfo::selected(WidgetType::SelectableLabel, ui.is_enabled(), on, *label) + }); + if !tip.is_empty() { + response = response.on_hover_text(tip); } - }) + if response.clicked() && !on { + *selected = *value; + changed = true; + } + if ui.is_rect_visible(rect) { + let hot = response.hovered() || response.has_focus(); + let ink = if on || hot { P.text } else { P.text_secondary }; + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, ink); + if on { + let y = rect.bottom() - 1.0; + ui.painter().line_segment( + [ + Pos2::new(rect.left() + space::MD, y), + Pos2::new(rect.right() - space::MD, y), + ], + stroke(2.0, P.text), + ); + } + if response.has_focus() { + ui.painter().rect_stroke( + rect.shrink2(Vec2::new(2.0, 10.0)), + CornerRadius::same(radius::SM), + stroke(1.5, P.accent), + StrokeKind::Outside, + ); + } + } + } + changed } -/// A row of mutually exclusive choices; the selected one is filled with the -/// accent. Returns true when the selection changed. +/// A row of mutually exclusive choices. The chosen one is filled white, +/// like "on" everywhere else. Returns true when the choice changed. /// /// Sized and allocated as one block, so it sits correctly in any parent /// layout (including the right-to-left side of a [`setting_row`]). @@ -452,8 +1396,8 @@ pub fn segmented( options: &[(T, &str)], selected: &mut T, ) -> bool { - let font = theme::medium(13.5); - let pad_x = 14.0; + let font = theme::medium(text::CAPTION + 0.5); + let pad_x = space::MD; let h = ui.spacing().interact_size.y; let galleys: Vec<_> = options .iter() @@ -466,13 +1410,23 @@ pub fn segmented( let total = Vec2::new(widths.iter().sum(), h); let (rect, block) = ui.allocate_exact_size(total, Sense::hover()); let visible = ui.is_rect_visible(rect); + if visible { + ui.painter().rect( + rect, + CornerRadius::same(radius::SM), + P.raised, + stroke(1.0, P.border), + StrokeKind::Inside, + ); + } let n = options.len(); let mut changed = false; let mut x = rect.left(); for (i, ((value, label), galley)) in options.iter().zip(galleys).enumerate() { - let seg = egui::Rect::from_min_size(egui::pos2(x, rect.top()), Vec2::new(widths[i], h)); + let seg = Rect::from_min_size(Pos2::new(x, rect.top()), Vec2::new(widths[i], h)); x += widths[i]; let resp = ui.interact(seg, block.id.with(i), Sense::click()); + keep_in_view(&resp); resp.widget_info(|| { WidgetInfo::selected( WidgetType::RadioButton, @@ -489,45 +1443,41 @@ pub fn segmented( continue; } let on = *selected == *value; - let r = RADIUS; + let r = radius::SM; let corner = CornerRadius { nw: if i == 0 { r } else { 0 }, sw: if i == 0 { r } else { 0 }, ne: if i + 1 == n { r } else { 0 }, se: if i + 1 == n { r } else { 0 }, }; + let hot = resp.hovered() || resp.has_focus(); let (fill, fg) = if on { - (P.accent_fill, P.on_accent) - } else if resp.hovered() { + (P.primary, P.on_primary) + } else if hot { (P.raised_hover, P.text) } else { - (P.raised, P.muted) + (Color32::TRANSPARENT, P.text_secondary) }; ui.painter() .rect(seg, corner, fill, Stroke::NONE, StrokeKind::Inside); + if i > 0 && !on && *selected != options[i - 1].0 { + ui.painter() + .vline(seg.left(), seg.y_range().shrink(7.0), stroke(1.0, P.border)); + } ui.painter() .galley(seg.center() - galley.size() / 2.0, galley, fg); - focus_ring(ui, &resp, RADIUS); - } - if visible { - ui.painter().rect_stroke( - rect, - CornerRadius::same(RADIUS), - stroke(1.0, P.border_strong.gamma_multiply(0.6)), - StrokeKind::Inside, - ); + focus_ring(ui, &resp, radius::SM); } changed } -/// An on/off switch. Returns the response; `changed()` fires on toggle. -pub fn toggle(ui: &mut Ui, on: &mut bool) -> Response { - labelled_toggle(ui, on, "") -} - fn labelled_toggle(ui: &mut Ui, on: &mut bool, label: &str) -> Response { - let size = Vec2::new(40.0, 22.0); - let (rect, mut response) = ui.allocate_exact_size(size, Sense::click()); + let track = size::SWITCH; + let (rect, mut response) = ui.allocate_exact_size( + Vec2::new(track.x, track.y.max(size::HIT_MIN)), + Sense::click(), + ); + keep_in_view(&response); if response.clicked() { *on = !*on; response.mark_changed(); @@ -535,36 +1485,211 @@ fn labelled_toggle(ui: &mut Ui, on: &mut bool, label: &str) -> Response { response .widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *on, label)); if ui.is_rect_visible(rect) { - let how_on = ui.ctx().animate_bool_responsive(response.id, *on); - let enabled = ui.is_enabled(); - let off = if response.hovered() { - P.raised_hover - } else { - P.raised - }; - let mut track = lerp_color(off, P.accent_fill, how_on); - let mut knob = lerp_color(P.muted, P.on_accent, how_on); - if !enabled { - track = track.gamma_multiply(0.5); + let t = ui.ctx().animate_bool_responsive(response.id, *on); + let track_rect = Rect::from_center_size(rect.center(), track); + let hot = response.hovered() || response.has_focus(); + let off = if hot { P.raised_hover } else { P.raised }; + let mut fill = lerp_color(off, P.primary, t); + let mut knob = lerp_color(P.text_tertiary, P.on_primary, t); + let mut line = lerp_color(P.border_strong, P.primary, t); + if !ui.is_enabled() { + fill = fill.gamma_multiply(0.5); knob = knob.gamma_multiply(0.5); + line = line.gamma_multiply(0.5); } - let radius = rect.height() / 2.0; + let r = track.y / 2.0; ui.painter().rect( - rect, - CornerRadius::same(radius as u8), - track, - stroke(1.0, P.border_strong.gamma_multiply(0.6 * (1.0 - how_on))), + track_rect, + CornerRadius::same(r as u8), + fill, + stroke(1.0, line), StrokeKind::Inside, ); - let x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); + let x = egui::lerp((track_rect.left() + r)..=(track_rect.right() - r), t); ui.painter() - .circle_filled(egui::pos2(x, rect.center().y), radius - 4.0, knob); - focus_ring(ui, &response, radius as u8); + .circle_filled(Pos2::new(x, track_rect.center().y), r - 4.0, knob); + if response.has_focus() && ui.is_enabled() { + ui.painter().rect_stroke( + track_rect.expand(2.0), + CornerRadius::same(r as u8 + 2), + stroke(1.5, P.accent), + StrokeKind::Outside, + ); + } + } + response +} + +/// A dropdown in the house style, with a painted chevron. `add` fills the +/// list with [`select_option`] rows. The list is no taller than the room on +/// the side of the control it opens towards, so it stays inside the window +/// and scrolls, with its scroll bar showing, when it has to. +pub fn select( + ui: &mut Ui, + id: impl std::hash::Hash, + selected: impl Into, + width: f32, + add: impl FnOnce(&mut Ui) -> R, +) -> Option { + // egui opens the list below the control when it fits there, and above + // it otherwise; cap it at the larger of the two rooms so either way it + // ends a margin inside the window. + let screen = ui.ctx().screen_rect(); + let y = ui.next_widget_position().y; + let h = ui.spacing().interact_size.y; + let below = screen.bottom() - space::SM - (y + h); + let above = y - h / 2.0 - space::XS - (screen.top() + space::SM); + let chrome = ui.spacing().menu_margin.sum().y + 2.0 * ui.visuals().window_stroke.width; + let height = (below.max(above) - chrome).clamp(size::CONTROL, 320.0); + let mut list = None; + let out = egui::ComboBox::from_id_salt(id) + .selected_text(selected) + .width(width) + // egui's own scroll area never clips; the one inside does. + .height(f32::INFINITY) + .icon(|ui, rect, visuals, _open, _| { + paint_icon( + ui.painter(), + rect.center(), + Icon::ChevronDown, + visuals.fg_stroke.color, + ); + }) + .show_ui(ui, |ui| { + let out = clipped_scroll(ui, "brolink.select", height, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().button_padding = Vec2::new(space::SM, 6.0); + add(ui) + }); + list = Some(out.inner_rect); + out.inner + }); + keep_in_view(&out.response); + if let Some(rect) = list { + // How egui's ComboBox names its popup. + let popup = out.response.id.with("popup"); + ui.ctx() + .data_mut(|d| d.insert_temp(open_menu_key(), (popup, rect))); + } + out.inner +} + +/// A row in a [`select`]'s list: chosen when clicked, marked while chosen, +/// and scrolled into view when Tab reaches it. +pub fn select_option( + ui: &mut Ui, + current: &mut T, + value: T, + label: impl Into, +) -> Response { + let response = ui.selectable_value(current, value, label); + keep_in_view(&response); + response +} + +/// A horizontal slider for a whole number, with its value in mono beside +/// it. Drag or click the track; with keyboard focus the arrow keys step by +/// one and Shift steps by ten. +pub fn slider( + ui: &mut Ui, + value: &mut u32, + range: std::ops::RangeInclusive, + width: f32, + label: &str, +) -> Response { + let (lo, hi) = (*range.start(), *range.end()); + let knob = 7.0; + let (rect, mut response) = ui.allocate_exact_size( + Vec2::new(width, size::HIT_MIN.max(ui.spacing().interact_size.y)), + Sense::click_and_drag(), + ); + keep_in_view(&response); + let rail = Rect::from_min_max( + Pos2::new(rect.left() + knob, rect.center().y - 2.0), + Pos2::new(rect.right() - knob, rect.center().y + 2.0), + ); + let before = *value; + if let Some(p) = response.interact_pointer_pos() { + if response.dragged() || response.clicked() { + let t = ((p.x - rail.left()) / rail.width()).clamp(0.0, 1.0); + *value = (lo as f32 + t * (hi - lo) as f32).round() as u32; + } + } + if response.has_focus() { + ui.memory_mut(|m| { + m.set_focus_lock_filter( + response.id, + egui::EventFilter { + horizontal_arrows: true, + vertical_arrows: true, + ..Default::default() + }, + ) + }); + let (up, down, big) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowDown), + i.modifiers.shift, + ) + }); + let step = if big { 10 } else { 1 }; + if up { + *value = value.saturating_add(step); + } + if down { + *value = value.saturating_sub(step); + } + } + *value = (*value).clamp(lo, hi); + if *value != before { + response.mark_changed(); + } + response.widget_info(|| WidgetInfo::slider(ui.is_enabled(), f64::from(*value), label)); + if ui.is_rect_visible(rect) { + let t = (*value - lo) as f32 / (hi - lo).max(1) as f32; + let x = egui::lerp(rail.left()..=rail.right(), t); + let enabled = ui.is_enabled(); + let r = CornerRadius::same(2); + ui.painter().rect_filled(rail, r, P.border_strong); + ui.painter().rect_filled( + Rect::from_min_max(rail.min, Pos2::new(x, rail.max.y)), + r, + if enabled { P.text_secondary } else { P.border }, + ); + let hot = response.hovered() || response.dragged() || response.has_focus(); + let c = Pos2::new(x, rail.center().y); + ui.painter().circle( + c, + if hot { knob + 1.0 } else { knob }, + if enabled { P.primary } else { P.text_disabled }, + stroke(2.0, P.surface), + ); + if response.has_focus() { + ui.painter() + .circle_stroke(c, knob + 4.0, stroke(1.5, P.accent)); + } } response } -fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { +/// The value beside a [`slider`], in a fixed width so the row does not +/// shift as it changes. +pub fn slider_value(ui: &mut Ui, text: &str) { + ui.allocate_ui_with_layout( + Vec2::new(64.0, size::HIT_MIN), + Layout::right_to_left(Align::Center), + |ui| { + ui.label( + RichText::new(text) + .font(theme::mono(text::MONO)) + .color(P.text), + ); + }, + ); +} + +pub(crate) fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8; Color32::from_rgba_unmultiplied( l(a.r(), b.r()), @@ -575,41 +1700,94 @@ fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { } // --------------------------------------------------------------------------- -// Settings rows +// Rows // --------------------------------------------------------------------------- +/// Lay out `add` left to right, pushed against the right edge of the space +/// left in `ui`. Controls sit where a right-to-left layout would put them, +/// but keyboard focus and screen readers meet them in reading order: egui +/// focuses widgets in the order they are made, and a right-to-left layout +/// makes the rightmost first. The width is measured on the previous pass; +/// when it changes, the pass is discarded and laid out again before +/// anything is shown. +pub fn trailing(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + let avail = ui.available_rect_before_wrap(); + let key = ui.auto_id_with("brolink.trailing"); + let known: f32 = ui.data(|d| d.get_temp(key)).unwrap_or(0.0); + let w = known.min(avail.width()).max(0.0); + let rect = Rect::from_min_max(Pos2::new(avail.max.x - w, avail.min.y), avail.max); + let mut child = ui.new_child( + UiBuilder::new() + .max_rect(rect) + .layout(Layout::left_to_right(Align::Center)), + ); + let inner = add(&mut child); + let used = child.min_rect(); + let got = if used.is_positive() { + used.width() + } else { + 0.0 + }; + if (got - known).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(key, got)); + ui.ctx().request_discard("trailing controls changed width"); + } + let response = ui.allocate_rect( + if used.is_positive() { + used + } else { + Rect::from_min_size(avail.right_top(), Vec2::ZERO) + }, + Sense::hover(), + ); + InnerResponse::new(inner, response) +} + /// A row with text on the left and controls on the right. The controls are /// laid out first, so the text gets exactly the width they leave and wraps /// there instead of running underneath them. When they leave too little, /// the text takes the full width below the controls. +/// +/// `min_h` is the row's height with padding; controls centre in it and +/// text starts `text_top` below its top. Controls are added left to right +/// (see [`trailing`]). fn split_row( ui: &mut Ui, - min_height: f32, + min_h: f32, + text_top: f32, right: impl FnOnce(&mut Ui) -> R, left: impl FnOnce(&mut Ui), ) -> R { let avail = ui.available_rect_before_wrap(); - let row = Rect::from_min_size(avail.min, Vec2::new(avail.width(), min_height)); + let row = Rect::from_min_size(avail.min, Vec2::new(avail.width(), min_h)); let mut right_ui = ui.new_child( UiBuilder::new() .max_rect(row) - .layout(Layout::right_to_left(Align::Center)), + .layout(Layout::top_down(Align::Min)), ); - right_ui.spacing_mut().item_spacing.x = 6.0; - let r = right(&mut right_ui); - let used = right_ui.min_rect(); + let placed = trailing(&mut right_ui, |ui| { + ui.spacing_mut().item_spacing.x = space::SM; + ui.set_min_height(min_h); + right(ui) + }); + let r = placed.inner; + let used = if placed.response.rect.width() > 0.0 { + placed.response.rect + } else { + Rect::NOTHING + }; let taken = if used.is_positive() { - row.max.x - used.min.x + 14.0 + row.max.x - used.min.x + space::LG } else { 0.0 }; let beside = row.width() - taken; let text_rect = if beside >= MIN_TEXT_BESIDE_CONTROLS || taken == 0.0 { - Rect::from_min_size(row.min, Vec2::new(beside, min_height)) + Rect::from_min_size(row.min + Vec2::new(0.0, text_top), Vec2::new(beside, min_h)) } else { Rect::from_min_size( - egui::pos2(row.min.x, used.max.y + 6.0), - Vec2::new(row.width(), min_height), + Pos2::new(row.min.x, used.max.y + space::XS), + Vec2::new(row.width(), min_h), ) }; let mut left_ui = ui.new_child( @@ -617,32 +1795,39 @@ fn split_row( .max_rect(text_rect) .layout(Layout::top_down(Align::Min)), ); - left_ui.spacing_mut().item_spacing.y = 1.0; + left_ui.spacing_mut().item_spacing.y = space::XXS; left(&mut left_ui); - let bottom = left_ui - .min_rect() - .max - .y - .max(used.max.y) - .max(row.min.y + min_height); + let bottom = (left_ui.min_rect().max.y + text_top) + .max(if used.is_positive() { + used.max.y + } else { + row.min.y + }) + .max(row.min.y + min_h); ui.allocate_rect( - Rect::from_min_max(row.min, egui::pos2(row.max.x, bottom)), + Rect::from_min_max(row.min, Pos2::new(row.max.x, bottom)), Sense::hover(), ); r } /// Below this, the text goes under the controls instead of beside them. -const MIN_TEXT_BESIDE_CONTROLS: f32 = 160.0; +const MIN_TEXT_BESIDE_CONTROLS: f32 = 180.0; -/// Label (and optional hint) on the left, a control on the right. +/// A settings row: a label (and optional hint) on the left, a control on +/// the right. pub fn setting_row( ui: &mut Ui, label: &str, hint: Option<&str>, control: impl FnOnce(&mut Ui) -> R, ) -> R { - split_row(ui, 30.0, control, |ui| { + let (h, top) = if hint.is_some() { + (56.0, 10.0) + } else { + (size::ROW, 13.0) + }; + split_row(ui, h, top, control, |ui| { ui.label(RichText::new(label).color(P.text)); if let Some(h) = hint { caption(ui, h); @@ -650,144 +1835,240 @@ pub fn setting_row( }) } -/// Third-party notices, as bundled at build time. Both windows show these. -pub const NOTICES: &str = include_str!("../../../NOTICE"); - -/// The Settings "Open source" row: a button that unfolds [`NOTICES`] in a -/// scrolling well beneath the row. -pub fn open_source_row(ui: &mut Ui, open: &mut bool) { - setting_row( - ui, - "Open source", - Some("BroLink is GPL-3.0 and builds on other free software."), - |ui| { - if ghost_button( - ui, - if *open { - "Hide notices" - } else { - "Show notices" - }, - ) - .clicked() - { - *open = !*open; - } - }, - ); - if *open { - well(ui, |ui| { - egui::ScrollArea::vertical() - .id_salt("open_source_notices") - .min_scrolled_height(160.0) - .max_height(240.0) - .show(ui, |ui| { - ui.label( - RichText::new(NOTICES) - .text_style(theme::caption()) - .color(P.muted), - ); - }); - }); +/// A settings row whose control is too wide to sit beside its label: the +/// label and hint, then the control under them. +pub fn setting_block( + ui: &mut Ui, + label: &str, + hint: Option<&str>, + control: impl FnOnce(&mut Ui) -> R, +) -> R { + ui.add_space(space::MD - 2.0); + ui.label(RichText::new(label).color(P.text)); + if let Some(h) = hint { + ui.add_space(space::XXS); + caption(ui, h); } + ui.add_space(space::SM); + let r = ui + .horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = Vec2::new(space::SM, space::SM); + control(ui) + }) + .inner; + ui.add_space(space::MD); + r } -/// A [`setting_row`] whose control is a [`toggle`]. Returns true on change. +/// A [`setting_row`] whose control is an on/off switch. Returns true on change. pub fn toggle_row(ui: &mut Ui, on: &mut bool, label: &str, hint: Option<&str>) -> bool { setting_row(ui, label, hint, |ui| { labelled_toggle(ui, on, label).changed() }) } -// --------------------------------------------------------------------------- -// Lists -// --------------------------------------------------------------------------- +/// Third-party notices, as bundled at build time. +pub const NOTICES: &str = include_str!("../../../NOTICE"); -/// A row in a list of PCs: name and detail on the left, actions on the -/// right. Add the primary action first; it lands rightmost. The detail -/// stays on one line and is cut with an ellipsis rather than wrapping. -pub fn list_row(ui: &mut Ui, title: &str, detail: &str, actions: impl FnOnce(&mut Ui)) { - split_row(ui, 40.0, actions, |ui| { - strong(ui, title); - ui.add( - Label::new( - RichText::new(detail) - .text_style(theme::caption()) - .color(P.muted), - ) - .truncate(), - ) - .on_hover_text(detail); +/// A row in a list of machines or devices: an optional status dot, the +/// name and one line of detail on the left, actions on the right, added in +/// reading order (left to right). The detail stays on one line +/// and is cut with an ellipsis rather than wrapping; the full text shows +/// on hover. +pub fn list_row( + ui: &mut Ui, + status: Option, + title: &str, + detail: &str, + actions: impl FnOnce(&mut Ui), +) { + let start = ui.available_rect_before_wrap().min; + let indent = if status.is_some() { 20.0 } else { 0.0 }; + split_row(ui, 60.0, 11.0, actions, |ui| { + ui.horizontal(|ui| { + ui.add_space(indent); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = space::XXS; + ui.add( + Label::new( + RichText::new(title) + .font(theme::medium(text::BODY)) + .color(P.text), + ) + .truncate(), + ); + ui.add( + Label::new( + RichText::new(detail) + .text_style(theme::caption()) + .color(P.text_secondary), + ) + .truncate(), + ) + .on_hover_text(detail); + }); + }); }); + if let Some(t) = status { + let c = Pos2::new(start.x + size::DOT / 2.0 + 1.0, start.y + 11.0 + 9.0); + ui.painter().circle_filled(c, size::DOT / 2.0, t.color()); + } } -/// Hairline between list rows. +/// Hairline between rows of a [`group`] or a card. pub fn row_separator(ui: &mut Ui) { - ui.add_space(4.0); let w = ui.available_width(); let (rect, _) = ui.allocate_exact_size(Vec2::new(w, 1.0), Sense::hover()); ui.painter() - .hline(rect.x_range(), rect.center().y, stroke(1.0, P.border)); - ui.add_space(4.0); + .hline(rect.x_range(), rect.center().y, stroke(1.0, P.hairline)); } // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- -/// A rounded chip with a coloured dot: READY, STREAMING, and so on. -pub fn status_pill(ui: &mut Ui, label: &str, tone: Tone) -> Response { - let color = tone.color(); - let galley = ui - .painter() - .layout_no_wrap(label.to_owned(), theme::medium(12.5), color); - let dot = 7.0; - let gap = 7.0; - let pad = Vec2::new(10.0, 5.0); +/// A status dot on its own. +pub fn status_dot(ui: &mut Ui, tone: Tone) -> Response { + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size::DOT), Sense::hover()); + ui.painter() + .circle_filled(rect.center(), size::DOT / 2.0, tone.color()); + response +} + +/// A dot and a short secondary label: "● Online", "● Tailscale off". +/// Painted as one piece, so the dot leads in any layout direction. +pub fn status_text(ui: &mut Ui, tone: Tone, label: &str) -> Response { + let galley = ui.painter().layout_no_wrap( + label.to_owned(), + theme::regular(text::CAPTION), + Color32::PLACEHOLDER, + ); + let gap = space::SM - 2.0; let size = Vec2::new( - galley.size().x + dot + gap + 2.0 * pad.x, - (galley.size().y + 2.0 * pad.y).max(26.0), + size::DOT + gap + galley.size().x, + galley.size().y.max(size::DOT), ); let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, label)); + if ui.is_rect_visible(rect) { + ui.painter().circle_filled( + Pos2::new(rect.left() + size::DOT / 2.0, rect.center().y), + size::DOT / 2.0, + tone.color(), + ); + ui.painter().galley( + Pos2::new( + rect.left() + size::DOT + gap, + rect.center().y - galley.size().y / 2.0, + ), + galley, + P.text_secondary, + ); + } + response +} + +/// A small outlined mono tag for a fact: "1920 × 1080", "HEVC". With a +/// tone, a status dot leads it. +pub fn tag(ui: &mut Ui, tone: Option, label: &str) -> Response { + let font = theme::mono(text::LABEL + 0.5); + let galley = ui + .painter() + .layout_no_wrap(label.to_owned(), font, Color32::PLACEHOLDER); + let dot = if tone.is_some() { size::DOT + 6.0 } else { 0.0 }; + let pad = space::SM; + let size = Vec2::new(galley.size().x + dot + 2.0 * pad, 22.0); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, label)); if ui.is_rect_visible(rect) { ui.painter().rect( rect, - CornerRadius::same(13), - color.gamma_multiply(0.14), - stroke(1.0, color.gamma_multiply(0.45)), + CornerRadius::same(radius::SM), + Color32::TRANSPARENT, + stroke(1.0, P.border), StrokeKind::Inside, ); - ui.painter().circle_filled( - egui::pos2(rect.left() + pad.x + dot / 2.0, rect.center().y), - dot / 2.0, - color, - ); + if let Some(t) = tone { + ui.painter().circle_filled( + Pos2::new(rect.left() + pad + size::DOT / 2.0, rect.center().y), + size::DOT / 2.0 - 0.5, + t.color(), + ); + } ui.painter().galley( - egui::pos2( - rect.left() + pad.x + dot + gap, + Pos2::new( + rect.left() + pad + dot, rect.center().y - galley.size().y / 2.0, ), galley, - color, + P.text_secondary, ); } response } -/// A small coloured dot followed by text, for inline status lines. +/// A status dot in the gutter and any content beside it, every line of it +/// starting at the text column: a checklist item with a link under its +/// text, say. +pub fn dot_item(ui: &mut Ui, tone: Tone, add: impl FnOnce(&mut Ui) -> R) -> R { + dotted(ui, tone, add) +} + +/// A small coloured dot followed by wrapped text, for checklists. pub fn dot_label(ui: &mut Ui, tone: Tone, text: &str) { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - ui.label(RichText::new("●").size(9.0).color(tone.color())); - ui.label(RichText::new(text).color(P.text)); + dotted(ui, tone, |ui| { + ui.add(Label::new(RichText::new(text).color(P.text)).wrap()); }); } +/// A key as printed on the keyboard, in a box. +pub fn keycap(ui: &mut Ui, key: &str) -> Response { + let font = theme::mono_medium(text::LABEL); + let galley = ui + .painter() + .layout_no_wrap(key.to_owned(), font, Color32::PLACEHOLDER); + let size = Vec2::new((galley.size().x + 10.0).max(20.0), 20.0); + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + if ui.is_rect_visible(rect) { + ui.painter().rect( + rect, + CornerRadius::same(radius::SM), + P.raised, + stroke(1.0, P.border_strong), + StrokeKind::Inside, + ); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, P.text); + } + response +} + +/// A chord as keycaps, then what it does: `[Ctrl] [Alt] releases the mouse`. +pub fn shortcut(ui: &mut Ui, keys: &[&str], does: &str) -> Response { + let label = format!("{} {does}", keys.join("+")); + let r = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = space::XS; + for k in keys { + keycap(ui, k); + } + ui.add_space(space::XS); + ui.label( + RichText::new(does) + .text_style(theme::caption()) + .color(P.text_secondary), + ); + }) + .response; + r.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); + r +} + // --------------------------------------------------------------------------- // Brand // --------------------------------------------------------------------------- -/// The app icon as a texture, for the window header. +/// The app icon as a texture, for the window's top bar. pub struct Brand { tex: TextureHandle, } @@ -802,24 +2083,27 @@ impl Brand { } } - /// Icon and product name on the left; `right` is laid out from the - /// right edge. - pub fn header(&self, ui: &mut Ui, title: &str, right: impl FnOnce(&mut Ui)) { + /// The mark and the product name, as on the product site. + pub fn lockup(&self, ui: &mut Ui, name: &str) { ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 10.0; - ui.add(egui::Image::new((self.tex.id(), Vec2::splat(28.0)))); + ui.spacing_mut().item_spacing.x = space::SM; + ui.add( + egui::Image::new((self.tex.id(), Vec2::splat(22.0))) + .corner_radius(CornerRadius::same(5)), + ); ui.label( - RichText::new(title) - .font(theme::semibold(17.0)) + RichText::new(name) + .font(theme::semibold(text::TITLE + 1.0)) + .extra_letter_spacing(-0.3) .color(P.text), ); - ui.with_layout(Layout::right_to_left(Align::Center), right); }); } } /// A paced activity indicator: unlike egui's immediate-repaint spinner this -/// stays bounded when the low-latency stream renderer has vsync disabled. +/// asks for at most 30 frames a second, and only while it is on screen, so +/// it stays bounded when the low-latency stream renderer has vsync off. pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { move |ui: &mut egui::Ui| { let (rect, response) = @@ -833,7 +2117,7 @@ pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { }) .collect(); ui.painter() - .add(egui::Shape::line(points, egui::Stroke::new(2.0_f32, color))); + .add(egui::Shape::line(points, egui::Stroke::new(1.5_f32, color))); ui.ctx() .request_repaint_after(std::time::Duration::from_millis(33)); } @@ -844,32 +2128,87 @@ pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { #[cfg(test)] mod tests { use super::*; + use crate::theme::contrast; - fn lin(c: u8) -> f64 { - let s = f64::from(c) / 255.0; - if s <= 0.04045 { - s / 12.92 - } else { - ((s + 0.055) / 1.055).powf(2.4) + #[test] + fn every_button_kind_keeps_its_label_readable() { + let surfaces = [P.bg, P.surface]; + for kind in [ + Kind::Primary, + Kind::Secondary, + Kind::Quiet, + Kind::Danger, + Kind::Destructive, + ] { + for (hovered, pressed) in [(false, false), (true, false), (true, true)] { + let l = look(kind, true, hovered, pressed); + for s in surfaces { + let behind = if l.fill.a() == 255 { + l.fill + } else if l.fill == Color32::TRANSPARENT { + s + } else { + // A translucent wash over the surface. + lerp_color(s, l.fill.to_opaque(), f32::from(l.fill.a()) / 255.0) + }; + let c = contrast(l.ink, behind); + assert!( + c >= 4.5, + "{kind:?} hovered={hovered} pressed={pressed}: {c:.2}:1" + ); + } + } + } + } + + #[test] + fn a_popup_never_leaves_the_window() { + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(640.0, 420.0)); + for y in [8.0, 40.0, 150.0, 260.0, 380.0] { + for wanted in [60.0, 170.0, 400.0, 900.0] { + let anchor = Rect::from_min_size(Pos2::new(560.0, y), Vec2::splat(32.0)); + let d = place_popup(screen, anchor, wanted, true); + let (top, bottom) = if d.above { + (d.pos.y - d.max_height, d.pos.y) + } else { + (d.pos.y, d.pos.y + d.max_height) + }; + assert!(top >= screen.top() + space::SM, "{y} {wanted}: {d:?}"); + assert!(bottom <= screen.bottom() - space::SM, "{y} {wanted}: {d:?}"); + assert!(d.max_height > 0.0); + } } } - fn contrast(fg: Color32, bg: Color32) -> f64 { - let lum = |c: Color32| 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()); - let (a, b) = (lum(fg), lum(bg)); - let (hi, lo) = if a > b { (a, b) } else { (b, a) }; - (hi + 0.05) / (lo + 0.05) + #[test] + fn a_popup_opens_upwards_only_when_it_needs_to_and_there_is_more_room() { + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(640.0, 420.0)); + let low = Rect::from_min_size(Pos2::new(560.0, 380.0), Vec2::splat(32.0)); + assert!(place_popup(screen, low, 170.0, true).above); + let middle = Rect::from_min_size(Pos2::new(560.0, 200.0), Vec2::splat(32.0)); + assert!( + !place_popup(screen, middle, 150.0, true).above, + "it fits below" + ); + let high = Rect::from_min_size(Pos2::new(8.0, 8.0), Vec2::splat(28.0)); + assert!(!place_popup(screen, high, 900.0, false).above); } #[test] - fn accent_button_text_meets_aa_in_all_states() { - let idle = P.accent_fill; - let hovered = lerp_color(idle, Color32::BLACK, 0.10); - let pressed = lerp_color(idle, Color32::BLACK, 0.12); - assert!(contrast(P.on_accent, idle) >= 4.5); - assert!(contrast(P.on_accent, hovered) >= 4.5); - assert!(contrast(P.on_accent, pressed) >= 4.5); - // Hover must darken, never lighten: lightening drops the ratio below AA. - assert!(contrast(P.on_accent, lerp_color(idle, Color32::WHITE, 0.10)) < 4.5); + fn tones_have_distinct_marks_except_the_two_plain_ones() { + assert_eq!(Tone::Neutral.color(), Tone::Info.color()); + let marks = [ + Tone::Neutral, + Tone::Accent, + Tone::Success, + Tone::Warning, + Tone::Danger, + ] + .map(Tone::color); + for (i, a) in marks.iter().enumerate() { + for b in &marks[i + 1..] { + assert_ne!(a, b); + } + } } } diff --git a/docs/MACOS.md b/docs/MACOS.md index e03a36c..45fb57b 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -1,9 +1,8 @@ # macOS client (Apple Silicon) BroLink for the Mac is one native app: every machine on your Tailscale -account, a Connect button each, a power menu, the stream itself, and -**Share this machine** so a Windows PC or another Mac can open this -desktop. BroLink's stream protocol is compiled in, video is decoded by +account, a Connect button each, a power menu, the stream itself, and a +**Sharing** tab so a Windows PC or another Mac can open this desktop. BroLink's stream protocol is compiled in, video is decoded by VideoToolbox (H.264 and HEVC), audio by Opus, and the picture is drawn in BroLink's own window under a toolbar. @@ -12,8 +11,8 @@ BroLink's own window under a toolbar. - macOS 13+ on Apple Silicon - [Tailscale](https://tailscale.com/download/mac), signed in with the same account as your other machines -- Screen Recording permission when you first **Share this machine** (so - others can see this Mac's desktop) +- Screen Recording permission for the streaming engine, which macOS asks + for once after you set up sharing (so others can see this Mac's desktop) ## Install a prebuilt app @@ -67,17 +66,21 @@ xcrun stapler staple dist/BroLink.app ## First run -1. If Tailscale is missing or signed out, the top card says so and **Get - Tailscale** opens the download page. -2. **Your PCs** lists the Windows machines on your Tailscale account. Each - line says whether it is ready, asleep, or missing something on the PC - side. -3. Click **Connect**. The first time with a PC, BroLink pairs with it: it - picks a random PIN, sends it to BroLink Host over Tailscale, and the host - enters it for you. You see the PIN but never need it. If there is no - BroLink Host on the PC, pairing cannot finish: open BroLink Host there - and run setup first. -4. The PC's desktop appears. +1. If Tailscale is missing or signed out, a banner at the top of + **Machines** says so; **Get Tailscale** opens the download page. +2. **Machines** lists every machine on your Tailscale account, this Mac + first. A dot and one line say whether each is ready, asleep, or + missing something on its side. +3. Click **Connect** (**Wake and connect** for a machine that is asleep). + The first time with a machine, BroLink pairs with it: it picks a + random PIN, sends it to BroLink on that machine over Tailscale, and + BroLink there enters it for you. You see the PIN but never need it. If + BroLink is not sharing on that machine, pairing cannot finish: open + BroLink there and set up sharing on its **Sharing** tab first. +4. The desktop appears. + +Command-1 opens **Machines**, Command-2 **Sharing** and Command-comma +**Settings**; Escape goes back to **Machines**. ## While streaming @@ -87,10 +90,13 @@ bar on any side; a window is resized to the stream's proportions when the session starts, for the same reason. A manual 16:9 size on a display of another shape leaves a bar above and below. -**Ctrl+Alt** is the host key, as in VMware or VirtualBox. Press it and the -mouse is freed and the toolbar drops over the top of the picture; press it -again, or click the picture, and the toolbar goes away and the mouse is -captured back. A menu or a question keeps the toolbar there until it is +**Control-Option** (Ctrl+Alt on Windows and Linux) is the host key, as in +VMware or VirtualBox. Press it and the mouse is freed and the toolbar drops +over the top of the picture; press it again, or click the picture, and the +toolbar goes away and the mouse is captured back. The keys are shown when +a stream starts, and on the toolbar itself. Before the first picture +arrives, a **Cancel** button under the progress ends the attempt without +the host key. A menu or a question keeps the toolbar there until it is answered. The status line, with the session clock, the frame rate and bitrate against what was asked for, and the round trip, is drawn over the picture's bottom-left corner while **Stats** is open, or in the bar below @@ -105,18 +111,21 @@ the Mac cursor's position is sent 1:1 instead and nothing is captured. | Toolbar item | What it does | |--------------|--------------| -| **● PC name · 1920×1080 · 60 fps · HEVC** | The stream as negotiated; the dot turns red while the connection is poor | -| **Direct · 38 ms / Relayed via Tokyo · 210 ms / Via your relay · 60 ms** | The path Tailscale found to the PC and its round trip. Red means every packet goes through a Tailscale relay, which adds delay; the lobby's **Connection details** say why and what would give a direct path. What BroLink asks for is the same on every path | -| **Mouse: captured / free** | Captured (the default): a click on the picture hides the cursor and sends raw movement, which games read; **Ctrl+Alt** frees it. Free: the Mac cursor's position is sent 1:1 and nothing is captured. The choice is remembered | -| **Keys** | Ctrl+Alt+Del, Windows key, Alt+Tab, Esc, Print Screen; and the switch for what ⌘ does on the PC | -| **Stats** | A Stream performance window: received against target bitrate and frame rate, round trip, packet loss, host, assembly, queue and decode times, and whether the decoder is hardware. **Copy diagnostics** puts it all on the clipboard | +| **● Machine name · 1920×1080 · 60 fps · HEVC** | The stream as negotiated (the numbers show on wide windows); the dot turns amber while the connection is poor | +| **Direct · 38 ms / Relayed via Tokyo · 210 ms / Via your relay · 60 ms** | The path Tailscale found and its round trip. Amber means every packet goes through one of Tailscale's shared relays, which adds delay; **Connection details** under the machine list say why and what would give a direct path. What BroLink asks for is the same on every path | +| **Mouse** | **Captured, for games** (the default): a click on the picture hides the cursor and sends raw movement, which games read; the host key frees it. **Free, for desktops**: the Mac cursor's position is sent 1:1 and nothing is captured. The choice is remembered, and is also in Settings | +| **Keys** | Ctrl+Alt+Delete, Windows key, Alt+Tab, Escape, Print Screen; and **Command acts as Ctrl** | +| **Stats** | The Stream performance panel: frame rate and bitrate against what was asked for, round trip, packet loss, encode, assembly, queue and decode times, and the decoder. **Copy diagnostics** puts it all on the clipboard | | **Full screen** | Toggle; the setting decides how a session starts | -| **PC** | Sleep, Restart…, Shut down… (the latter two ask twice); **Update BroLink Host…** for a PC whose host is older than 3.1 | -| **Stream settings** | The same panel as in the lobby: quick profiles, picture quality, resolution, frame rate, bitrate target and video format. **Apply and reconnect** restarts the session with them in a few seconds | +| **PC** (or **Mac**, **Machine**) | Sleep, Restart…, Shut down… (the latter two ask first); **Update BroLink Host…** for a PC whose host is older than 3.1 | +| **Stream settings** | The same controls as Settings: quality profile, resolution, frame rate, bitrate and codec. **Apply and reconnect** restarts the session with them in a second or two; Escape or the close button leaves them as they were | | **Disconnect** | End the session | -Everything else on the keyboard goes to the PC as pressed. With **⌘ acts -as Ctrl** on (the default), ⌘C, ⌘V, ⌘Z and the rest do on Windows what +On a narrow window everything but **Stream settings** and **Disconnect** +folds into one **More** menu. + +Everything else on the keyboard goes to the PC as pressed. With **Command +acts as Ctrl** on (the default), ⌘C, ⌘V, ⌘Z and the rest do on Windows what they do on the Mac, and ⌘ can stay held across several of them. ### Clipboard @@ -132,7 +141,7 @@ something crossed. With an older host, ⌘V pastes what the PC last copied. Tailscale connects the Mac and the PC directly when it can punch through both routers. When it cannot, every packet goes through a relay: your own -peer relay if you run one (the Relay card in Settings), otherwise one of +peer relay if you run one (**Relay** in Settings), otherwise one of Tailscale's (DERP). A relay adds a detour, and the round trip it adds is a floor under how quickly the PC answers a click; it changes nothing about what BroLink asks for. Recommended quality is this screen's size at 50 @@ -149,39 +158,43 @@ fix it: UPnP or NAT-PMP turned on in the PC's router, a UDP port forwarded to the PC, or IPv6 on both networks. Both machines report their own side (`tailscale netcheck`); the PC's report needs BroLink Host 3.1. -## The PC menu in the lobby +## A machine's menu -Next to a ready PC, **PC** offers **Sleep**, **Restart…**, **Shut -down…** and **Test wake**. Restart and shut down ask for a second click; -both force-close programs on the PC. The menu only appears if the host -allows remote power actions. +The **…** button at the end of each row offers what that machine allows: +**Wake** (asleep, and BroLink has learned how to wake it; sends the packet +without connecting), **Test waking it from this network** (awake), **Sleep**, +**Restart…** and **Shut down…** (when that machine allows remote power +actions), and **Copy Tailscale address**. Restart and shut down ask first; +both force-close programs there. -**Test wake** sends the wake packet to the awake PC and asks BroLink Host -whether it arrived. A pass means waking the PC from where you are will -work; a failure means the packets are not reaching it from this network -(see below). **Wake** appears next to a PC that is asleep and whose wake -details BroLink has learned; it sends the packet without connecting. +**Test waking it from this network** sends the wake packet to the awake +machine and asks BroLink there whether it arrived. A pass means waking it +from where you are will work; a failure means the packets are not reaching +it from this network (see below). ## Settings | Setting | What it changes | |---------|-----------------| -| Quick profiles | **Smooth** 1080p · 60 fps · 20 Mbps, **Balanced** match screen · 60 · 50, **Sharp** match screen · 60 · 100; **Reset** returns to Recommended | -| Picture quality | **Recommended** (the default) asks for this screen's own size at 50 Mbps, on every path. **Manual** uses the rows below; changing the resolution or the bitrate switches to it | +| Quality | **Recommended** (the default) asks for this screen's own size at 50 Mbps, on every path. **Smooth** is 1080p · 60 fps · 20 Mbps, **Balanced** match screen · 60 · 50, **Sharp** match screen · 60 · 100. **Custom** uses the rows below; changing one of them switches to it | | Resolution | **Match screen** is this display's own pixel size (3024×1964 on a 14" MacBook Pro), so the picture fills it exactly. **1080p**, **1440p** and **4K** are the standard 16:9 sizes, 1920×1080, 2560×1440 and 3840×2160; on a display of another shape they leave a bar above and below. The list shows the pixel size of each. The PC switches its display to the size asked for; a PC with no monitor needs a virtual display that lists that size, which setup on the PC arranges for the Virtual Display Driver | | Frame rate | 30 to 240; the PC's display is switched to match when it can | -| Bitrate target | 2 to 150 Mbps. The encoder holds this rate. Large swings mean the path is dropping packets | -| Video format | Auto (HEVC when the PC can encode it), HEVC, H.264 | -| Full screen | How a session starts | -| App | The app on the PC to launch; "Desktop" is the whole PC. The list fills in after the first connection | -| Command key acts as Ctrl | Off makes ⌘ the Windows key | -| Offer to sleep the PC after each session | Off by default. Asleep, Tailscale is off; this Mac can only wake the PC from that PC's own network | +| Bitrate | 2 to 150 Mbps; arrow keys step by 1, Shift-arrow by 10. The encoder holds this rate. Large swings mean the path is dropping packets | +| Codec | Auto (HEVC when both ends can), HEVC, H.264 | +| Open streams full screen | How a session starts | +| Capture the mouse | The toolbar's Mouse choice, remembered | +| Command acts as Ctrl | Off makes ⌘ the Windows key (Mac only) | +| App to open | The app on the PC to launch; "Desktop" is the whole screen. The list fills in after the first connection | +| Offer to sleep a machine after a session | Off by default. Asleep, Tailscale is off there; this Mac can only wake it from its own network | +| Keep BroLink up to date | Updates this app and the Windows PCs' BroLink Host; **Check now** checks at once | +| Relay | Whether a relay of your own is in use, and the grant it needs | +| About | Version, licence, source, and the open-source notices | The stream rows are the panel that **Stream settings** opens during a -session, and the pill under them says exactly what the next connect asks -for; the lobby's **Next session** card shows the same. A session also -reconnects by itself, at the new size, when this Mac's window moves to a -display of a different size. +session, and **Next stream** under them says exactly what the next connect +asks for; the machine list shows the same. A session also reconnects by +itself, at the new size, when this Mac's window moves to a display of a +different size. Settings are saved in `~/Library/Application Support/dev.brolink.BroLink/client.toml`, along with the MAC, LAN address and pairing certificate of each PC BroLink @@ -190,7 +203,7 @@ has paired with. ## Waking a PC from another network Asleep, the PC's Tailscale is off, so BroLink cannot reach it over the -tailnet. Leave the PC on (BroLink Host keeps it awake while plugged in) +tailnet. Leave the PC on (BroLink keeps it awake while plugged in) if you want **Connect** from anywhere. The wake packet is sent to the LAN broadcast, to the PC's LAN address and diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index a0c51cb..9215052 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -18,7 +18,8 @@ streaming engine, which ships in the same zip, does the capture. 1. Unzip the release and run `brolink-host.exe`, or run `install-host.ps1` to copy it (and the bundled engine archive) to `%LOCALAPPDATA%\BroLink`, add shortcuts, and register the background service to start at logon. -2. Click **Share this machine**. One UAC prompt runs a script that: +2. Open the **Sharing** tab and click **Set up as administrator**. One UAC + prompt runs a script that: - unpacks the bundled engine archive into `%ProgramFiles%\BroLink\engine` (downloads that pinned release if the archive is not beside the exe), and gives its executables BroLink's @@ -44,18 +45,19 @@ streaming engine, which ships in the same zip, does the capture. device wake the PC (`powercfg /deviceenablewake`). The script's transcript is in `%LOCALAPPDATA%\BroLink\setup.log` and is - shown in the window if something fails. Every step is idempotent; run - setup again after fixing whatever it complained about. -3. The status pill turns green. Leave the window closed; the service keeps - running. + shown on the Sharing tab if something fails. Every step is idempotent; + run setup again after fixing whatever it complained about. +3. The Sharing tab says **Shared** with a green dot. Leave the window + closed; the service keeps running. Advanced engine settings are not exposed anywhere in BroLink: BroLink configures the engine itself. ## Slow streams: the network, or the encoder -The **This PC** card has a **Network** line from `tailscale netcheck`. "Hard -NAT with no UPnP" means a Mac on another network can only reach this PC +The **This PC** section of the Sharing tab has a **Network** line from +`tailscale netcheck`. "Hard NAT without UPnP" means a machine on another +network can only reach this PC through a Tailscale relay, which adds a detour. What the Mac asks for is the same either way, so a relayed stream is laggier, not blurrier; the Mac shows the same thing as **Relayed via …** next to the PC. Turn UPnP (or NAT-PMP) on in the router, or forward a UDP port to @@ -131,8 +133,8 @@ It supplies a display even when the physical monitor is off and works with BroLi bundled engine. Setup lists every screen size a Mac can ask for in the driver's settings file, and the engine switches the virtual display to the Mac's size at each connect, so there is nothing to choose in Windows Display settings. If the -driver was installed after setup, run **Set up this PC** again; the setup card -says when sizes are missing. Installing a display does not require pairing the +driver was installed after setup, choose **Run setup again** on the Sharing +tab; its setup list says when sizes are missing. Installing a display does not require pairing the Mac again. ## Using an engine you installed yourself @@ -148,8 +150,9 @@ own engine; the API and config layout are the same. ## Gamepads The engine no longer installs the virtual controller driver (ViGEmBus) by -itself. The **This PC** card shows whether it is present and offers -**Install controller driver**; a reboot afterwards is recommended. +itself. The **This PC** section of the Sharing tab shows whether it is +present and offers **Install controller driver**; a reboot afterwards is +recommended. ## Troubleshooting @@ -157,22 +160,24 @@ itself. The **This PC** card shows whether it is present and offers switch to the size the Mac asked for. On a PC with the Virtual Display Driver, run setup again so that size is listed; a monitor only offers its own sizes, and the stream is scaled from the nearest. -- **Orange "Needs setup" that will not clear**: read `setup.log`. The +- **Amber "Needs setup" that will not clear**: open **Setup log** on the + Sharing tab, or read `setup.log`. The usual causes are the UAC prompt being dismissed, or a driver that refuses the wake keywords. -- **The Mac lists the PC as online but with no BroLink Host**: the control - service is not running or the firewall rule is missing. Open BroLink Host - (it restarts the service) and run setup again. +- **Other machines list the PC as online, but "BroLink isn't installed"**: + the control service is not running or the firewall rule is missing. Open + BroLink on the PC (it restarts the service) and choose **Run setup again** + on the Sharing tab. - **The Mac says the PC is asleep but it is on**: Tailscale's online flag lags by up to half a minute; Connect probes the PC directly and will work. -- **Test wake on the Mac says the packet did not arrive**: the packet is +- **Test waking it from this network says the packet did not arrive**: the packet is being sent but not delivered. From another network the PC's router has to forward UDP 9 to the PC's LAN address (give the PC a DHCP reservation so the address is stable). On the same network, check that Windows classes it as Private; a Public network drops the broadcast. - **Test wake passes but the PC does not wake**: the network path is fine - and Windows is the problem. Check the This PC card for "off" or "Fast - Startup on" and run setup again; for wake from power off, look for the + and Windows is the problem. Check **Wake-on-LAN** on the Sharing tab for + "Off" or "Fast Startup is on" and run setup again; for wake from power off, look for the firmware setting named above. ## Updates @@ -203,8 +208,9 @@ automatic. The background service starts with Windows by default and sets that again at every start, so a PC nobody can reach in person comes back after a -restart; the toggle in Settings is the only thing that turns it off. -It also holds Windows awake while plugged in (same Settings card), because +restart; **Start BroLink when you log in** on the Sharing tab is the only +thing that turns it off. It also holds Windows awake while plugged in +(**Keep this PC awake while plugged in**, same place), because a sleeping PC's Tailscale is asleep and a Mac on another network cannot wake it. The engine runs as a Windows service and streams the sign-in screen, so a diff --git a/docs/design/AUDIT.md b/docs/design/AUDIT.md new file mode 100644 index 0000000..e2970c4 --- /dev/null +++ b/docs/design/AUDIT.md @@ -0,0 +1,171 @@ +# UI audit and redesign, 4.0.2 → unreleased + +A harsh pass over every screen of the unified window, the Sharing page +and the stream overlay, as rendered by the snapshot harness and as read +in code. Severity is the effect on someone using BroLink, not the effort +to fix. Every item was found on 4.0.2 (`f96e1a3`); the last column is +its state on this branch. + +Renders: `cargo test -p brolink-client -p brolink-host snapshots -- --ignored` +writes `target/ui-snapshots/`. Names below refer to that folder +(`client-lobby-1280x800@2x.png` and so on). + +## Findings + +### Structure and flows + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| A1 | Critical | The unified window never drew the host's panel. `NodeApp` used `HostApp` only for its state; the panel with this machine's Tailscale, engine, network, Wake-on-LAN and controller status, the paired devices and Unpair, the service log, Open log folder, Stop service and Repair was reachable only from snapshot tests. On a real install none of it could be seen. | Fixed: the **Sharing** tab is that page, drawn by the host crate inside the window (`SharePage`). | +| A2 | High | The snapshot harness loaded the real `client.toml` and `host.toml`, so renders showed the developer's own settings (20 Mbps, 1080p in the 4.0.2 set) and a test that clicked a switch would have written them. | Fixed: headless constructors with default settings that never save; host actions are inert when headless. | +| A3 | High | The six relay-state renders were byte-identical: the Relay card sat below the bottom of a 1100-point render, so the harness showed none of the states it named. | Fixed: relay states are asserted by query at the minimum size, and `*-full.png` renders whole pages. | +| A4 | High | `client-stream-toolbar.png` never showed the toolbar (it is hidden until the host key), the tests that did draw it were named so the `snapshots` filter skipped them, and two tests wrote the same file. | Fixed: `stream-*` renders cover the toolbar at every size and each menu, panel and overlay. | +| A5 | Medium | Renders existed at one or two sizes, mostly 2×, with three machines. No many-machine list, long names, empty, looking, offline, Tailscale-stopped, updating, pairing-stuck, confirm or session-ended states. | Fixed: every state at 640×420, 1280×800 and 1920×1200, at 1× and 2×, plus unignored fit tests at the minimum and large sizes. | +| A6 | Medium | Settings was a toggle ("Settings" / "Close settings") that replaced the list; there was no navigation, and no way to reach settings from the keyboard. | Fixed: tabs (Machines, Sharing, Settings) with Command/Ctrl-1, 2, comma and Escape. | +| A7 | Medium | "This machine" in the list offered only Share or Repair; the paired devices, options and diagnostics lived in a window no one saw (A1), and the three sharing switches were in the viewer's Settings, away from the thing they configure. | Fixed: the row summarises and opens Sharing; the switches live there. | + +### Performance and threads + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| P1 | High | The idle window repainted every 400 ms forever (`request_repaint_after(400ms)` on every frame), and the host poller asked for a repaint every second whether anything changed or not. | Fixed: nothing repaints on a timer when idle (tested: `an_idle_window_asks_for_no_repaint`); the poller repaints on change. | +| P2 | High | Every two seconds the UI thread called `tailscale::cli()`, which, when Tailscale is not installed, spawns `tailscale --version` and waits for it. | Fixed: probed on its own thread. | +| P3 | Medium | Unpair, Install controller driver and the setup request (a 600 ms HTTP probe plus a process spawn) ran on the UI thread. | Fixed: worker threads, with the result shown as a notice. | +| P4 | Low | `NodeApp` cloned the host's whole status, service log included, into the viewer every frame, stream frames included. | Fixed: copied only when it changes (`Shared::rev`). | +| P5 | — | Stream view per-frame cost. | Unchanged: the toolbar, menus and panels are laid out only while shown; notices ask for a 500 ms repaint only while up. | + +### Visual design + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| V1 | High | No design system: one palette struct, but sizes chosen inline (13.5, 12.5, 17, 28, 30, 40 pt), screen-local `semibold(28.0)` headlines, an inline `egui::Grid` for stats, inline colours. | Fixed: tokens in `theme.rs` (below); screens use only tokens and components. | +| V2 | High | The app did not look like the product site: violet, pink and cyan chrome in Inter against bardbro.com's black, white, hairlines and Geist. | Fixed: Geist and Geist Mono, neutral surfaces, white primary, colour only for status. | +| V3 | Medium | A marketing headline inside the app ("Your workspace, anywhere.") at 30 pt, pushing the machine list below the fold at 640×420. | Fixed: a plain page title. | +| V4 | Medium | Status pills used as data chips ("1920 × 1080" in cyan, "60 fps" in grey): colour that meant nothing. | Fixed: mono tags; colour only on status. | +| V5 | Medium | Three filled violet Connect buttons in a three-row list: the "primary" colour repeated until it said nothing. | Fixed: outlined Connect; one white primary per screen at most. | +| V6 | Medium | Four control styles side by side: egui's framed menu buttons ("PC ⌄"), frameless ghost buttons, egui's filled-triangle dropdowns, egui's default collapsing triangle, and "●/○" glyphs as radio buttons in menus. | Fixed: one button family, painted chevrons, check marks, popup menus. | +| V7 | Medium | Notices glued the dot to the text ("●Gaming-PC's…") and wrapped the second line under the dot. | Fixed: a fixed gutter; every line starts at the same x. | +| V8 | Medium | Host setup card: the caption shared a wrapping row with the button and wrapped under it; checklist lines 14 pt apart. | Fixed. | +| V9 | Low | The stream toolbar was a rectangle with a white hairline on all four sides. | Fixed: bottom hairline only. | +| V10 | Low | Bitrate slider: large hollow knob, violet trail, a drag box beside it. | Fixed: a slim slider with a mono value, arrow-key steps. | +| V11 | Low | The App field was 20 pt tall beside 30-pt controls. | Fixed. | +| V12 | Low | A notice's quiet action ("Dismiss", "Cancel") sat 12 points right of the text above it: its label is inset by the button's padding, and no fill or outline shows the box. A notice with no actions kept an empty row's height; the setup card's "Get Tailscale" link started left of the item it belongs to. | Fixed: `action_row` pulls a leading quiet button back by its padding; an empty row takes no room; `dot_item` puts the link in the item's text column. | + +### Contrast (WCAG 2.2 AA, computed) + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| C1 | High | Faint text `#5c6676` (footer, small print, stream status line): 3.45:1 on the window, 3.22:1 on cards, 3.62:1 on black. Fails 4.5:1. | Fixed: tertiary `#8c8c8c`, ≥ 4.5:1 on every surface up to a hovered control. | +| C2 | High | Destructive confirm button: `#e8ecf2` on `#f85149`, 2.83:1. | Fixed: white on `#cd2b31`, 5.27:1. | +| C3 | Medium | Violet pill text on its own tint ("Connecting", "Needs setup"): 3.89:1. | Fixed: status text is secondary grey beside a coloured dot. | +| C4 | Low | Violet accent as text at 4.51:1 on cards: passing by 0.01. | Fixed: the accent is `#a78bfa` (6.9:1) and is never text on a fill. | + +All pairs are asserted in `crates/ui/src/theme.rs` and `widgets.rs` tests. + +### Copy and behaviour + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| U1 | High | "This Mac" everywhere, on Windows and Linux viewers too: errors, key-expiry notices, path explanations, settings hints. | Fixed: "this machine", or the right noun per OS. | +| U2 | High | Errors sent people to a UI that no longer exists: "Open BroLink Host on the PC and run setup", "Check Wake-on-LAN in BroLink Host". | Fixed: they point at the Sharing tab and say what to do. | +| U3 | Medium | "Keep this machine awake while plugged in" on Mac and Linux, where `keep_awake` does nothing. | Fixed: Windows only. | +| U4 | Medium | "Command key acts as Ctrl" on Windows and Linux viewers, which have no Command key. | Fixed: Mac only. | +| U5 | Medium | Connect buttons vanished while any connection ran, reflowing the list. | Fixed: disabled in place, with a tooltip. | +| U6 | Medium | "Wake" beside "Connect", which also wakes. | Fixed: "Wake and connect"; Wake alone is in the row's menu. | +| U7 | Medium | The lobby's power menu was "PC" for every OS and hid Test wake behind power permissions. | Fixed: a "…" menu with what each machine allows, plus Copy Tailscale address. | +| U8 | Medium | While the app updated itself the connection card had an empty title. | Fixed: "Installing a BroLink update". | +| U9 | Medium | Every failure offered "Try again at Smooth (1080p · 20 Mbps)", even a wake failure it cannot help, and a connection that never started was titled "… disconnected". | Fixed: "Couldn't connect to …" or "… disconnected"; the lighter profile only for stream failures. | +| U10 | Medium | "Connecting failed at {stage} (code {code})." | Fixed: says the machine answered and points at the network. | +| U11 | Medium | Pairing called the other side "BroLink Host"; a stuck PIN hand-off showed as a violet notice. | Fixed: PIN in boxes, plain explanation, amber notice. | +| U12 | Medium | "Tailscale: Tailscale is stopped." beside "Sign in to Tailscale to see your machines" over a list of remembered machines. | Fixed: one banner that says what happened and what to do; the list stays. | +| U13 | Medium | Stream quality was two controls for one choice: four unselectable "quick profile" buttons plus Recommended/Manual, and a Reset. | Fixed: one control, Recommended · Smooth · Balanced · Sharp · Custom. | +| U14 | Low | Settings was one long card; the licence only as raw NOTICE text. | Fixed: grouped sections and an About section. | +| U15 | Low | Relay copy: "That is not a denial." | Fixed: "That does not mean access is denied." | +| U16 | Low | A key expiring in 176 days shown in a bright notice on the main page. | Fixed: folded into Connection details; within 30 days it goes to the top in red. | +| U17 | Medium | Open log folder ran `explorer` on every OS. | Fixed: `open` on macOS, `xdg-open` on Linux. | +| U18 | Low | Setup failure showed the script's raw error. | Fixed: what happened and what to do, raw error in small print. | +| U19 | Medium | No way out of "Connecting to …" in the stream view without knowing the host key. | Fixed: a Cancel button. | +| U20 | Medium | The host key was only a five-second text toast, and read "Ctrl+Alt" on a Mac keyboard labelled control and option. | Fixed: keycaps at stream start and on the toolbar, named per platform. | +| U21 | Low | Mouse menu items were whole sentences with "●" prefixes; Keys had "○ ⌘ acts as Ctrl (now the Windows key)". | Fixed. | +| U22 | Low | Stream performance used egui's title bar and a raw grid; its "Auto · Balanced" label did not match Settings' "Recommended". | Fixed. | +| U23 | Low | Stream settings used egui's title bar; Escape did nothing. | Fixed. | +| U24 | Medium | Black picture: the fix (Turn off HDR) was a quiet button after Restart stream, and the headline kept "Asking the PC why…" after the answer. | Fixed. | +| U25 | Low | A "Next session" card of pills plus a "Configure stream" button repeated the settings summary. | Fixed: one mono line with a Change link. | +| U26 | Low | "Paired Macs", "Start the background service with Windows" on a Mac. | Fixed. | +| U27 | Low | The poor-network toast said "Open Stats" without saying how to reach the toolbar. | Fixed. | +| U28 | Low | The Sharing page's network line said "a Mac … reaches this PC" on a Mac. | Fixed in the page; the service log's own line is unchanged. | +| U29 | Low | Phones on the tailnet were listed as "BroLink isn't installed". | Fixed. | +| U30 | Low | A relayed path was red, like a failure. | Fixed: amber, with the explanation in Connection details. | + +### Accessibility and input + +| # | Severity | Finding | State | +|---|----------|---------|-------| +| X1 | High | Tab order ran backwards through right-aligned controls (egui focuses in creation order; right-to-left layouts create the rightmost first): a machine's menu before its Connect, the toolbar from Disconnect leftwards. | Fixed: `trailing` layout; tested. | +| X2 | Medium | Focus rings drawn inside controls in text colour, and egui's own widgets showed focus only as the pressed style. | Fixed: a violet ring outside every custom control and on built-in ones. | +| X3 | Medium | No window shortcuts. | Fixed: Command/Ctrl-1, 2, comma; Escape. | +| X4 | Low | Switch hit target 40×22 (< 24). | Fixed: 36×24. | +| X5 | Low | Pills and other painted text were missing from the accessibility tree. | Fixed: status, tags, keycaps and the PIN report labels. | +| X6 | High | At 640×420 the stream toolbar's More menu ran past the bottom of the window: "Command acts as Ctrl" and the PC items could not be reached, by pointer or keyboard. Stream performance ran off the bottom, and panels sat under the toolbar. Tab could move focus to a control scrolled out of sight. | Fixed: menus open where they fit (upwards when there is more room above) and scroll inside when neither side has room; dropdown lists, panels and questions do the same, below the toolbar; Tab scrolls the focused control into view; focus rings are no longer cut at a window's edge. Tested at 640×420 by rect, with snapshots. | + +## Design system + +All in `crates/ui/src/theme.rs`; components in `widgets.rs`. + +**Colour** (dark only, by design) + +| Token | Value | Use | +|-------|-------|-----| +| `bg` | `#0a0a0a` | Window, elevation 0 | +| `surface` | `#111111` | Cards, grouped lists, panels, elevation 1 | +| `raised` / `raised_hover` / `pressed` | `#1a1a1a` / `#242424` / `#2e2e2e` | Controls and their states | +| `well` | `#060606` | Logs, code | +| `overlay` | `#080808` at 92% | Panels over the stream | +| `hairline` / `border` / `border_strong` | `#242424` / `#363636` / `#4a4a4a` | Separators, control outlines, hovered outlines | +| `text` / `text_secondary` / `text_tertiary` / `text_disabled` | `#ededed` / `#a1a1a1` / `#8c8c8c` / `#5e5e5e` | 16.9, 7.7, 5.9:1 on `bg` | +| `primary` (+ hover, pressed) / `on_primary` | `#ededed` / `#0a0a0a` | The one main action; a switch that is on; the chosen segment | +| `accent` | `#a78bfa` | Keyboard focus, selection, progress. Never a fill behind text | +| `success` / `warning` / `danger` | `#4cc38a` / `#f0b249` / `#ff6369` | Ready / needs attention / broken | +| `danger_fill` | `#cd2b31` | Behind white text on a confirming destructive button | + +**Type**: Geist and Geist Mono (OFL 1.1). Page title 22 semibold, −0.44 +tracking · card title 15 semibold · body 14 regular, controls 14 medium · +caption 12.5 · data 12.5 mono · section label 11 mono medium, uppercase, ++0.66 tracking (the site's label style) · PIN 32 mono medium. + +**Spacing**: 2 · 4 · 8 · 12 · 16 · 24 · 32 · 48. Page gutter 24; content +column 880 (lists) or 720 (forms). + +**Sizes**: control 32, compact control 28, minimum hit target 24, top bar +48, toolbar 44, list and settings rows 44 to 60, status dot 8, switch 36×20. + +**Radii**: 4 controls and keycaps · 6 cards, lists, notices, menus · 10 +panels over the stream. + +**Elevation**: 0 window · 1 surface with a hairline, no shadow · popup +(menus, dropdowns) shadow 0/8/24 at 55% black · overlay (stream panels) +shadow 0/12/32 at 63% black. + +**Components**: buttons in five kinds (primary, secondary, quiet, danger, +destructive), icon button, link, popup menu with items, choices, labels, +notes and separators, tabs, segmented control, switch, slider, select, +setting row and block, list row, grouped list and section, card and toned +card, notice and banner, key/value grid, disclosure, well and log, +status dot and text, tag, keycap and shortcut, PIN digits, spinner, +brand lockup, top and bottom bars, overlay and panel frames. + +## Not done, and not verified + +- The stream picture itself: every stream render has no video behind the + overlay, because the harness has no engine to connect to. How the + toolbar and notices read over a real, bright desktop is unverified. +- Real windows: font hinting at 1× on Windows, Retina at 2× on a Mac, + VoiceOver and Narrator reading the accesskit tree, IME, and the + Command/Ctrl shortcuts with a real keyboard were not tried; the rules + forbid launching the app here. Windows-only code compiles in CI only. +- The service's own log lines (`service.rs`) still say "the Mac"; they + are logs, and the file belongs to the service. +- `Live::quality_label` in `session.rs` has no caller now; the stream + overlay names profiles as Settings does. +- The product site's screenshots show the 4.0.2 window. +- No light theme.