From 499858551593e0ca4ef437c695f744fae2840667 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:59 -0700 Subject: [PATCH] feat(desktop): add the tray usage popup Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/gui.rs | 203 ++++++++++++++++++++- crates/switchyard-desktop/src/gui/panel.rs | 183 +++++++++++++++++++ crates/switchyard-desktop/ui/tray.css | 60 ++++++ crates/switchyard-desktop/ui/tray.html | 36 ++++ crates/switchyard-desktop/ui/tray.js | 80 ++++++++ 5 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 crates/switchyard-desktop/src/gui/panel.rs create mode 100644 crates/switchyard-desktop/ui/tray.css create mode 100644 crates/switchyard-desktop/ui/tray.html create mode 100644 crates/switchyard-desktop/ui/tray.js diff --git a/crates/switchyard-desktop/src/gui.rs b/crates/switchyard-desktop/src/gui.rs index c866521ae..c91f35d12 100644 --- a/crates/switchyard-desktop/src/gui.rs +++ b/crates/switchyard-desktop/src/gui.rs @@ -3,6 +3,9 @@ //! Tauri manages the window and tray, and runs blocking operations on worker threads. +#[cfg(target_os = "macos")] +mod panel; + use crate::controller::{Action, Controller, Reply, Snapshot}; use crate::summary::Overview; use std::path::PathBuf; @@ -14,7 +17,7 @@ use std::time::Duration; use tauri::{ Manager, State, menu::{Menu, MenuItem, PredefinedMenuItem}, - tray::TrayIconBuilder, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, }; struct Shared { @@ -267,6 +270,13 @@ async fn action(app: tauri::AppHandle, action: Action) -> Result dispatch(app, action).await } #[tauri::command] +async fn tray_overview(app: tauri::AppHandle) -> Result { + // The overview probes the server, so it runs on a worker thread. + tauri::async_runtime::spawn_blocking(move || refresh_tray(&app)) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] fn quit_app(app: tauri::AppHandle) { quit(&app); } @@ -394,6 +404,132 @@ fn accessibility() -> serde_json::Value { serde_json::json!({"native_material": false, "reduce_transparency": true, "reduce_motion": false}) } +/// POPUP_WIDTH matches the width of macOS status item menus such as Wi-Fi and Sound. +const POPUP_WIDTH: f64 = 320.0; + +// The webview measures popup height; invalid sizes must not reach AppKit. +fn popup_height(height: f64) -> Result { + if !height.is_finite() || height <= 0.0 { + return Err("The popup height must be a positive number.".into()); + } + Ok(height.clamp(120.0, 620.0)) +} + +#[tauri::command] +fn tray_size(app: tauri::AppHandle, height: f64) -> Result<(), String> { + let height = popup_height(height)?; + #[cfg(target_os = "macos")] + app.state::().resize(height); + #[cfg(windows)] + if let Some(window) = app.get_webview_window("tray") { + let height = window + .current_monitor() + .map_err(|e| e.to_string())? + .map(|monitor| { + height.min(f64::from(monitor.work_area().size.height) / monitor.scale_factor()) + }) + .unwrap_or(height); + window + .set_size(tauri::LogicalSize::new(POPUP_WIDTH, height)) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +fn tray_page(app: tauri::AppHandle, page: String) -> Result<(), String> { + if !matches!(page.as_str(), "overview" | "install" | "settings" | "setup") { + return Err("Choose Overview, Connect tools, or Settings.".into()); + } + open_page(&app, &page); + dismiss_tray(app) +} +#[tauri::command] +fn dismiss_tray(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + app.state::().dismiss(); + #[cfg(windows)] + if let Some(window) = app.get_webview_window("tray") { + window.hide().map_err(|error| error.to_string())?; + } + Ok(()) +} +// Windows uses physical coordinates to keep the popup within the target monitor's work area. +#[cfg(windows)] +fn popup_geometry( + rect: tauri::Rect, + area: &tauri::PhysicalRect, + scale: f64, +) -> (tauri::PhysicalPosition, tauri::PhysicalSize) { + let origin = rect.position.to_physical::(scale); + let icon = rect.size.to_physical::(scale); + let size = tauri::PhysicalSize::new( + (POPUP_WIDTH * scale) + .min(f64::from(area.size.width)) + .round() as u32, + (620.0 * scale).min(f64::from(area.size.height)).round() as u32, + ); + let x = (origin.x + icon.width / 2.0 - f64::from(size.width) / 2.0).clamp( + f64::from(area.position.x), + f64::from(area.position.x) + f64::from(area.size.width - size.width), + ); + let y = (origin.y + icon.height + 6.0 * scale).clamp( + f64::from(area.position.y), + f64::from(area.position.y) + f64::from(area.size.height - size.height), + ); + ( + tauri::PhysicalPosition::new(x.round() as i32, y.round() as i32), + size, + ) +} + +fn toggle_tray( + tray: &tauri::tray::TrayIcon, + position: tauri::PhysicalPosition, + rect: tauri::Rect, +) -> tauri::Result<()> { + let app = tray.app_handle(); + #[cfg(target_os = "macos")] + { + let _ = (position, rect); + // AppKit frames avoid converting Tauri's physical, top-left coordinates back to points. + let Some((anchor, area)) = panel::status_item_frames(tray) else { + return Ok(()); + }; + if app.state::().toggle(anchor, area) + && let Some(window) = app.get_webview_window("tray") + { + window.eval("window.dispatchEvent(new Event('switchyard-tray-open'))")?; + } + Ok(()) + } + #[cfg(windows)] + { + let Some(window) = app.get_webview_window("tray") else { + return Ok(()); + }; + if window.is_visible()? { + return window.hide(); + } + if let Some(monitor) = window.available_monitors()?.into_iter().find(|monitor| { + let origin = monitor.position(); + let size = monitor.size(); + position.x >= f64::from(origin.x) + && position.x < f64::from(origin.x) + f64::from(size.width) + && position.y >= f64::from(origin.y) + && position.y < f64::from(origin.y) + f64::from(size.height) + }) { + let (position, size) = + popup_geometry(rect, monitor.work_area(), monitor.scale_factor()); + window.set_position(position)?; + window.set_size(size)?; + } + window.show()?; + window.set_focus()?; + window.eval("window.dispatchEvent(new Event('switchyard-tray-open'))")?; + Ok(()) + } +} fn open_page(app: &tauri::AppHandle, page: &str) { if let Some(window) = app.get_webview_window("main") { // Only the fixed page names from the tray menu reach this script. @@ -422,6 +558,9 @@ fn activation(app: &tauri::AppHandle, visible: bool) -> tauri::Result<()> { } } fn show(app: &tauri::AppHandle) { + if let Err(error) = dismiss_tray(app.clone()) { + eprintln!("switchyard-desktop: could not hide the preview: {error}"); + } if let Some(window) = app.get_webview_window("main") && let Err(e) = activation(app, true) .and_then(|()| window.show()) @@ -449,6 +588,10 @@ pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { choose_path, appearance, accessibility, + tray_size, + tray_page, + tray_overview, + dismiss_tray, quit_app ]) .setup(|app| { @@ -460,6 +603,34 @@ pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { tauri::window::EffectState::FollowsWindowActiveState, ))?; } + let tray_window = tauri::WebviewWindowBuilder::new( + app, + "tray", + tauri::WebviewUrl::App("tray.html".into()), + ) + .title("Switchyard usage") + .inner_size(POPUP_WIDTH, 620.0) + .transparent(cfg!(target_os = "macos")) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .focused(false) + .build()?; + #[cfg(target_os = "macos")] + { + // Like a menu, the popup keeps the active material although it never activates + // the app. + tray_window.set_effects(material( + tauri::window::Effect::Popover, + Some(panel::CORNER_RADIUS), + tauri::window::EffectState::Active, + ))?; + app.manage(panel::TrayPanel::new(&tray_window).map_err(std::io::Error::other)?); + } + #[cfg(windows)] + let _ = tray_window; let tray_pixels = include_bytes!("../icons/tray.rgba"); TrayIconBuilder::with_id(TRAY_ID) @@ -467,6 +638,22 @@ pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { .icon_as_template(false) .tooltip("Switchyard — routes and usage") .menu(&tray_menu(app.handle(), &["Reading usage…".into()])?) + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + position, + rect, + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + && let Err(error) = toggle_tray(tray, position, rect) + { + eprintln!( + "switchyard-desktop: could not show or hide the usage preview: {error}" + ); + } + }) .on_menu_event(|app, event| match event.id.as_ref() { "restart" => run_tray_action(app, Action::Restart {}), "open-config" => run_tray_action(app, Action::OpenConfig {}), @@ -499,10 +686,22 @@ pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { } } } + #[cfg(windows)] + if window.label() == "tray" + && matches!(event, tauri::WindowEvent::Focused(false)) + && let Err(error) = window.hide() + { + eprintln!("switchyard-desktop: could not hide the usage preview: {error}"); + } if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - + if window.label() == "tray" { + if let Err(error) = dismiss_tray(window.app_handle().clone()) { + eprintln!("switchyard-desktop: could not hide the usage preview: {error}"); + } + return; + } // Accessory removes the running Dock entry while keeping the tray menu. if let Err(e) = window.hide().and_then(|()| { if window.label() == "main" { diff --git a/crates/switchyard-desktop/src/gui/panel.rs b/crates/switchyard-desktop/src/gui/panel.rs new file mode 100644 index 000000000..5b1acd36c --- /dev/null +++ b/crates/switchyard-desktop/src/gui/panel.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module configures a nonactivating tray panel to join other apps' fullscreen Spaces. + +use dispatch2::MainThreadBound; +use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, rc::Retained}; +use objc2_app_kit::{ + NSApplication, NSBackingStoreType, NSColor, NSEvent, NSEventType, NSPanel, + NSPopUpMenuWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask, +}; +use objc2_foundation::{MainThreadMarker, NSProcessInfo, NSRect}; +use std::cell::Cell; + +/// MENU_GAP matches the space macOS leaves between the menu bar and a status item's menu. +const MENU_GAP: f64 = 5.0; + +/// CORNER_RADIUS matches the corners of macOS 26 menus; the glass and tray.css use it too. +pub(super) const CORNER_RADIUS: f64 = 12.0; + +// Like a status item's menu, the panel's left edge follows the icon and its top edge sits +// just below the menu bar. AppKit supplies points, so the frame needs no scale conversion. +fn popup_frame(anchor: NSRect, area: NSRect, height: f64) -> NSRect { + let mut frame = area; + frame.size.width = frame.size.width.min(super::POPUP_WIDTH); + frame.size.height = frame.size.height.min(height); + frame.origin.x = anchor.origin.x.clamp( + area.origin.x, + area.origin.x + area.size.width - frame.size.width, + ); + let top = anchor.origin.y.min(area.origin.y + area.size.height) - MENU_GAP; + frame.origin.y = (top - frame.size.height).clamp( + area.origin.y, + area.origin.y + area.size.height - frame.size.height, + ); + frame +} + +/// This function returns the status item's frame and its screen's visible frame in points. +pub(super) fn status_item_frames(tray: &tauri::tray::TrayIcon) -> Option<(NSRect, NSRect)> { + tray.with_inner_tray_icon(|inner| { + let mtm = MainThreadMarker::new()?; + let window = inner.ns_status_item()?.button(mtm)?.window()?; + Some((window.frame(), window.screen()?.visibleFrame())) + }) + .ok() + .flatten() +} + +#[derive(Default)] +struct PopupState { + anchor: Cell, + area: Cell, + height: Cell, +} + +define_class!( + // SAFETY: The panel is initialized as an NSPanel, and every method runs on the main thread. + #[unsafe(super = NSPanel)] + #[thread_kind = MainThreadOnly] + #[ivars = PopupState] + struct Popup; + + impl Popup { + #[unsafe(method(canBecomeKeyWindow))] + fn can_become_key(&self) -> bool { true } + + #[unsafe(method(canBecomeMainWindow))] + fn can_become_main(&self) -> bool { false } + + #[unsafe(method(resignKeyWindow))] + fn resign_key(&self) { + // SAFETY: These inherited AppKit methods have no additional requirements. + unsafe { + let _: () = msg_send![super(self), resignKeyWindow]; + let mouse = NSEvent::mouseLocation(); + let anchor = self.ivars().anchor.get(); + // A left-click on the tray icon must reach toggle_tray before focus loss hides the panel. + let tray_click = NSApplication::sharedApplication(self.mtm()).currentEvent() + .is_some_and(|event| matches!(event.r#type(), NSEventType::LeftMouseDown | NSEventType::LeftMouseUp)); + if !tray_click || mouse.x < anchor.origin.x || mouse.x > anchor.origin.x + anchor.size.width + || mouse.y < anchor.origin.y || mouse.y > anchor.origin.y + anchor.size.height { + self.orderOut(None); + } + } + } + } +); + +// MainThreadBound also releases the panel on the main thread during shutdown. +pub(super) struct TrayPanel(MainThreadBound>); + +impl TrayPanel { + pub(super) fn new(window: &tauri::WebviewWindow) -> Result { + let mtm = MainThreadMarker::new().ok_or("The tray panel must start on the main thread.")?; + let pointer = window.ns_window().map_err(|error| error.to_string())?; + // SAFETY: Tauri supplies a live NSWindow; its hidden window retains the webview and IPC registration. + unsafe { + let source = &*pointer.cast::(); + let view = source + .contentView() + .ok_or("The tray webview has no content view.")?; + let responder = source.firstResponder(); + let allocated = Popup::alloc(mtm).set_ivars(PopupState { + anchor: Cell::new(NSRect::default()), + area: Cell::new(source.frame()), + height: Cell::new(620.0), + }); + let panel: Retained = msg_send![super(allocated), + initWithContentRect: source.frame(), + styleMask: NSWindowStyleMask::NonactivatingPanel, + backing: NSBackingStoreType::Buffered, + defer: false + ]; + panel.setReleasedWhenClosed(false); + panel.setOpaque(false); + panel.setBackgroundColor(Some(&NSColor::clearColor())); + panel.setHasShadow(true); + panel.setTitle(&source.title()); + panel.setHidesOnDeactivate(false); + panel.setBecomesKeyOnlyIfNeeded(false); + panel.setLevel(NSPopUpMenuWindowLevel); + let mut behavior = NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::Stationary + | NSWindowCollectionBehavior::IgnoresCycle; + if NSProcessInfo::processInfo() + .operatingSystemVersion() + .majorVersion + >= 13 + { + behavior |= NSWindowCollectionBehavior::CanJoinAllApplications; + } + panel.setCollectionBehavior(behavior); + source.setContentView(None); + panel.setContentView(Some(&view)); + // Clipping the glass and the webview to one rounded shape also rounds the shadow. + view.setWantsLayer(true); + if let Some(layer) = view.layer() { + layer.setCornerRadius(CORNER_RADIUS); + layer.setMasksToBounds(true); + } + panel.makeFirstResponder(responder.as_deref()); + Ok(Self(MainThreadBound::new(panel, mtm))) + } + } + + pub(super) fn dismiss(&self) { + self.0.get_on_main(|panel| panel.orderOut(None)); + } + + pub(super) fn resize(&self, height: f64) { + self.0.get_on_main(|panel| { + let state = panel.ivars(); + state.height.set(height); + panel.setFrame_display( + popup_frame(state.anchor.get(), state.area.get(), height), + true, + ); + panel.invalidateShadow(); + }); + } + + /// This function shows the panel below `anchor`, the status item's frame, within `area`, + /// the visible frame of the status item's screen. It returns false when it hid the panel. + pub(super) fn toggle(&self, anchor: NSRect, area: NSRect) -> bool { + self.0.get_on_main(|panel| { + // A panel visible on another Space must be shown here rather than hidden by this click. + if panel.isVisible() && panel.isOnActiveSpace() { + panel.orderOut(None); + return false; + } + let state = panel.ivars(); + state.anchor.set(anchor); + state.area.set(area); + panel.setFrame_display(popup_frame(anchor, area, state.height.get()), true); + panel.orderFrontRegardless(); + panel.makeKeyWindow(); + panel.invalidateShadow(); + true + }) + } +} diff --git a/crates/switchyard-desktop/ui/tray.css b/crates/switchyard-desktop/ui/tray.css new file mode 100644 index 000000000..c16318a3f --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.css @@ -0,0 +1,60 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ +/* The popup follows macOS menu bar menus: the system font and label colors, the system + light or dark appearance, the native material behind transparent content, and menu rows + that highlight with the accent color. AppKit clips the panel to rounded corners, so the + page itself draws nothing outside #popup. */ +:root { + --label: rgba(0, 0, 0, .85); --secondary: rgba(0, 0, 0, .55); --separator: rgba(0, 0, 0, .1); + --fill: rgba(0, 0, 0, .05); --opaque: #ECECEC; --success: #1A8B3A; --warning: #B25000; --danger: #C42B1C; + --bar: #CD744F; + font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; cursor: default; +} +@media (prefers-color-scheme: dark) { + :root { --label: rgba(255, 255, 255, .88); --secondary: rgba(255, 255, 255, .58); --separator: rgba(255, 255, 255, .12); + --fill: rgba(255, 255, 255, .07); --opaque: #2C2C2E; --success: #32D74B; --warning: #FF9F0A; --danger: #FF6961; } +} +* { box-sizing: border-box; } +/* A background on html or body would fill the whole rectangular page, so only #popup paints. */ +html, body { margin: 0; overflow: hidden; background: transparent; } +#popup { display: flex; flex-direction: column; max-height: 100vh; padding: 5px; border-radius: 12px; color: var(--label); } +/* macOS materials already become opaque with Reduce Transparency. Windows has no material and + an opaque rectangular window, so the popup fills it. */ +:root[data-native-material="false"] #popup { border-radius: 0; background: var(--opaque); } + +header { flex-shrink: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 6px 9px 2px; } +header strong { font-weight: 600; } +.connection { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--secondary); white-space: nowrap; } +.connection::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--warning); } +.connection.running::before { background: var(--success); } + +main { flex: 0 1 auto; min-height: 0; overflow: auto; padding: 0 9px 4px; color-scheme: light dark; } +p { margin: 0; } +.detail { font-size: 12px; color: var(--secondary); overflow-wrap: anywhere; } +#server-url { margin-bottom: 2px; } +#error { margin: 6px 0; padding: 6px 8px; border-radius: 6px; background: var(--fill); font-size: 12px; color: var(--danger); white-space: pre-line; overflow-wrap: anywhere; } +#empty, #note { margin-top: 8px; } +h2 { margin: 10px 0 2px; font-size: 12px; font-weight: 600; color: var(--secondary); } +.period p { font-variant-numeric: tabular-nums; } +.period p + p { margin-top: 1px; } +.period .saved { color: var(--success); } +.period .detail { overflow-wrap: anywhere; } + +#model-list { display: grid; gap: 5px; margin: 4px 0 0; padding: 0; list-style: none; } +#model-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; row-gap: 3px; } +#model-list .model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#model-list .share { font-size: 12px; color: var(--secondary); font-variant-numeric: tabular-nums; } +#model-list .track { grid-column: 1 / -1; height: 4px; border-radius: 2px; background: var(--fill); overflow: hidden; } +#model-list .track i { display: block; height: 100%; border-radius: 2px; background: var(--bar); } + +footer { flex-shrink: 0; } +nav { display: grid; padding: 4px 0; } +/* Like menu separators, the line stops short of the rounded edges. */ +nav::before { content: ""; height: 1px; margin: 0 9px 4px; background: var(--separator); } +nav:last-child { padding-bottom: 0; } +button { all: unset; display: block; height: 24px; padding: 0 9px; border-radius: 6px; line-height: 24px; color: var(--label); white-space: nowrap; } +button.active:not(:disabled) { background: #0A84FF; background: -apple-system-control-accent; color: #FFFFFF; } +button:focus { outline: none; } +#status:empty { display: none; } +#status { padding: 2px 9px 4px; font-size: 12px; color: var(--secondary); white-space: pre-line; overflow-wrap: anywhere; } diff --git a/crates/switchyard-desktop/ui/tray.html b/crates/switchyard-desktop/ui/tray.html new file mode 100644 index 000000000..00efc2db1 --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.html @@ -0,0 +1,36 @@ + + + + + + +Switchyard usage + + + + + + diff --git a/crates/switchyard-desktop/ui/tray.js b/crates/switchyard-desktop/ui/tray.js new file mode 100644 index 000000000..607e9d5b7 --- /dev/null +++ b/crates/switchyard-desktop/ui/tray.js @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const invoke=window.__TAURI__.core.invoke,$=id=>document.getElementById(id); +let refreshing=false,refreshTimer,running=false; +function node(tag,text,className) {const element=document.createElement(tag);if(text!==undefined)element.textContent=text;if(className)element.className=className;return element;} +function message(text) {$('error').textContent=text;$('error').hidden=!text;} +function show(id,text) {$(id).textContent=text||'';$(id).hidden=!text;} +// The tray menu shows the same overview as text rows; the popup groups it into sections. +function render(view) { + const overview=view.overview; + message(view.errors.join('\n')); + $('connection').textContent=overview?(overview.running?'Server running':'Server not responding'):'Usage unavailable'; + $('connection').classList.toggle('running',Boolean(overview?.running)); + show('server-url',overview?.server_url); + show('empty',overview?.empty); + $('periods').replaceChildren(...(overview?.periods||[]).map(period=>{ + const section=node('section',undefined,'period'); + section.append(node('h2',period.name),node('p',period.traffic),node('p',period.spent,'detail')); + if(period.saved)section.append(node('p',period.saved,'detail saved')); + return section; + })); + const models=overview?.models||[]; + $('models').hidden=!models.length; + $('model-list').replaceChildren(...models.map(share=>{ + const row=node('li'),name=node('span',share.model,'model'),track=node('span',undefined,'track'),bar=node('i'); + name.title=share.model;bar.style.width=`${share.percent}%`;track.append(bar); + row.append(name,node('span',`${share.percent.toFixed(0)}%`,'share'),track); + return row; + })); + show('note',overview?.note); +} +async function refresh() { + clearTimeout(refreshTimer);if(refreshing)return;refreshing=true;let seconds=5; + try {const view=await invoke('tray_overview');seconds=view.refresh_seconds;render(view);} + catch(error){message(`Could not read usage: ${error}`);} + finally{refreshing=false;if(document.hasFocus())refreshTimer=setTimeout(refresh,seconds*1000);} +} +async function command(name,args) {try{await invoke(name,args);}catch(error){message(`Could not open or close the window: ${error}`);}} +const actionButtons=[...document.querySelectorAll('[data-action]')]; +for(const button of actionButtons)button.onclick=async()=>{ + if(running)return;running=true;actionButtons.forEach(other=>{other.disabled=true;}); + $('status').textContent=button.dataset.action==='restart'?'Restarting the server…':'Opening the server config…'; + try {const reply=await invoke('action',{action:{kind:button.dataset.action}});$('status').textContent=reply.message;} + catch(error){$('status').textContent=String(error);} + finally{running=false;actionButtons.forEach(other=>{other.disabled=false;});refresh();} +}; +for(const button of document.querySelectorAll('[data-page]'))button.onclick=()=>command('tray_page',{page:button.dataset.page}); +$('quit').onclick=()=>command('quit_app'); +// Rows highlight like menu items: under the pointer or from the arrow keys, and never left +// over from the last time the popup was open. +const rows=[...document.querySelectorAll('nav button')]; +function highlight(row) {rows.forEach(other=>other.classList.toggle('active',other===row));if(row)row.focus({preventScroll:true});else document.activeElement?.blur();} +for(const row of rows){row.onpointerenter=()=>highlight(row);row.onpointerleave=()=>highlight(null);} +document.addEventListener('keydown',event=>{ + if(event.key==='Escape'){command('dismiss_tray');return;} + if(event.key!=='ArrowDown'&&event.key!=='ArrowUp')return; + event.preventDefault(); + const enabled=rows.filter(row=>!row.disabled),current=enabled.indexOf(document.activeElement); + const next=event.key==='ArrowDown'?(current+1)%enabled.length:(current<=0?enabled.length:current)-1; + highlight(enabled[next]); +}); +window.addEventListener('switchyard-tray-open',()=>{highlight(null);refresh();});window.addEventListener('focus',refresh); +window.addEventListener('blur',()=>{clearTimeout(refreshTimer);highlight(null);}); +// Loading once at startup gives the first opening its final height instead of a resize. +refresh(); + +// The native panel takes its height from the content, up to the limit that tray_size applies. +let measuredHeight=0; +function sizePopup() { + const style=getComputedStyle($('popup')),padding=parseFloat(style.paddingTop)+parseFloat(style.paddingBottom); + const height=Math.ceil(padding+document.querySelector('header').offsetHeight+document.querySelector('main').scrollHeight+document.querySelector('footer').offsetHeight); + if(height!==measuredHeight){measuredHeight=height;command('tray_size',{height});} +} +if(typeof ResizeObserver==='function') { + const observer=new ResizeObserver(sizePopup); + for(const tag of ['header','main','footer'])observer.observe(document.querySelector(tag)); + new MutationObserver(()=>requestAnimationFrame(sizePopup)).observe(document.body,{childList:true,subtree:true,characterData:true}); +} +window.addEventListener('switchyard-tray-open',sizePopup);