From db01c58a6f1723112167acb5d0666778f2bfe436 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 2 Oct 2026 16:00:23 +0100 Subject: [PATCH] Tray: Phosphor icons, click-to-copy fields with feedback, steady plot column - Bundle egui-phosphor (pinned to egui 0.31) and register its glyphs, so the icon buttons render real icons instead of tofu: refresh, shuffle (generate secret), pencil/check/x (rename host), copy (Copy secret). - Copying is now the field itself, not a separate button: clicking the shortened device id, the network name, the network id, a range or an address copies the full value and the field flashes green "Copied" for a second (tracked in egui temp memory, so it clears itself). The standalone copy buttons are gone. - In the devices window the connected-peers "rate / plot" column put the rate and the inline sparkline in a shared row, so the plot shifted as the rate text changed width ("0 B/s" vs "1.1 kB/s"). The rate now sits in a fixed-width, truncating cell, so the plot stays put. Builds and is clippy-clean (-D warnings) on macOS; the UI has not been run in this headless session. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 14 ++++++- crates/tsunagi-tray/Cargo.toml | 1 + crates/tsunagi-tray/src/app.rs | 6 +++ crates/tsunagi-tray/src/devices.rs | 33 +++++++-------- crates/tsunagi-tray/src/format.rs | 66 ++++++++++++++++++------------ crates/tsunagi-tray/src/ui.rs | 37 +++++++++-------- 6 files changed, 96 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ae8d6a..932bbcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,7 +187,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "x11rb", ] @@ -1454,6 +1454,15 @@ dependencies = [ "profiling", ] +[[package]] +name = "egui-phosphor" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171d57331491e92f0a7c73fad1088716e3200984309f4f8684731b750cee1378" +dependencies = [ + "egui", +] + [[package]] name = "egui-wgpu" version = "0.31.1" @@ -5046,7 +5055,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6319,6 +6328,7 @@ name = "tsunagi-tray" version = "0.1.0" dependencies = [ "eframe", + "egui-phosphor", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/tsunagi-tray/Cargo.toml b/crates/tsunagi-tray/Cargo.toml index 87db689..5fbcb4c 100644 --- a/crates/tsunagi-tray/Cargo.toml +++ b/crates/tsunagi-tray/Cargo.toml @@ -24,6 +24,7 @@ tray-icon = "0.21" tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } tracing.workspace = true tracing-subscriber.workspace = true +egui-phosphor = "0.9" # macOS only: set the app's activation policy to Accessory so it lives in the # menu bar with no Dock icon. Pinned to eframe's winit so the types unify. diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index 69f80fc..8364363 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -31,6 +31,12 @@ impl App { runtime: tokio::runtime::Handle, ) -> Result { let ctx = cc.egui_ctx.clone(); + // Add the Phosphor icon glyphs to the font set so the icon buttons + // render instead of showing as tofu. + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + ctx.set_fonts(fonts); + let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket()); let tray = Tray::new(ctx)?; Ok(Self { diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 5406508..eb64e7e 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -77,10 +77,7 @@ fn summary(ui: &mut egui::Ui, network: &NetworkReport) { .spacing([12.0, 3.0]) .show(ui, |ui| { ui.weak("id"); - ui.horizontal(|ui| { - ui.monospace(&network.network_id); - format::copy_button(ui, &network.network_id); - }); + format::copy_field(ui, &network.network_id, &network.network_id); ui.end_row(); ui.weak("active"); @@ -92,12 +89,12 @@ fn summary(ui: &mut egui::Ui, network: &NetworkReport) { if let Some(range) = &network.range { ui.weak("range"); - format::copy_label(ui, range, range); + format::copy_field(ui, range, range); ui.end_row(); } if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) { ui.weak("this device"); - format::copy_label(ui, address, address); + format::copy_field(ui, address, address); ui.end_row(); } ui.weak("candidates"); @@ -153,10 +150,10 @@ fn connected_table( let name = hostnames .get(peer.endpoint_id.as_str()) .map_or_else(|| format::short(&peer.public_key), |h| (*h).to_string()); - format::copy_label(ui, &name, &name); + format::copy_field(ui, &name, &name); ui.label(&peer.protocol); match &peer.address { - Some(address) => format::copy_label(ui, address, address), + Some(address) => format::copy_field(ui, address, address), None => { ui.weak("—"); } @@ -173,7 +170,11 @@ fn connected_table( )); let series = traffic.peer(network_id, &peer.public_key); ui.horizontal(|ui| { - ui.label(format::series_rate(series, unit)); + // A fixed-width rate so the inline plot does not shift as + // the number's length changes (e.g. "0 B/s" vs "1.1 kB/s"). + ui.allocate_ui(egui::vec2(64.0, 18.0), |ui| { + ui.add(egui::Label::new(format::series_rate(series, unit)).truncate()); + }); ui.allocate_ui(egui::vec2(90.0, 20.0), |ui| { format::sparkline(ui, series, unit, 18.0); }); @@ -203,13 +204,13 @@ fn offline_table( let name = hostnames .get(member.endpoint_id.as_str()) .map_or_else(|| format::short(&member.endpoint_id), |h| (*h).to_string()); - format::copy_label(ui, &name, &member.endpoint_id); - ui.label( - member - .overlay_address_v4 - .clone() - .unwrap_or_else(|| "—".into()), - ); + format::copy_field(ui, &name, &member.endpoint_id); + match &member.overlay_address_v4 { + Some(address) => format::copy_field(ui, address, address), + None => { + ui.weak("—"); + } + } ui.label(member.failed_dials.to_string()); ui.end_row(); } diff --git a/crates/tsunagi-tray/src/format.rs b/crates/tsunagi-tray/src/format.rs index 307c86a..40bfd8d 100644 --- a/crates/tsunagi-tray/src/format.rs +++ b/crates/tsunagi-tray/src/format.rs @@ -1,5 +1,7 @@ //! Formatting and small shared widgets: humanized ages, byte sizes and rates, -//! a fixed-size sparkline, and click-to-copy labels. +//! a fixed-size sparkline, and a click-to-copy field with "Copied" feedback. + +use std::time::{Duration, Instant}; use eframe::egui; @@ -7,6 +9,10 @@ use crate::stats::{Series, Unit}; /// Sparkline accent. const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); +/// The green a field flashes when its value is copied. +const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); +/// How long the "Copied" feedback stays. +const FLASH: Duration = Duration::from_secs(1); /// A short, readable prefix of a long identifier (char-safe, no panic). pub(crate) fn short(id: &str) -> String { @@ -61,6 +67,38 @@ pub(crate) fn rate(value: f32, unit: Unit) -> String { } } +/// The current rate of a series as a string, or a dash when unsampled. +pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String { + series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit)) +} + +/// A label whose text is `display` and which copies `value` to the clipboard +/// when clicked, flashing green with "Copied" for a second as feedback. The +/// copy affordance is the field itself — no separate button. +pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) { + let id = ui.make_persistent_id(("copy", value, display)); + let copied_at: Option = ui.memory(|memory| memory.data.get_temp(id)); + let flashing = copied_at.is_some_and(|at| at.elapsed() < FLASH); + + let text = if flashing { + egui::RichText::new(format!("{display} Copied")).color(COPIED_GREEN) + } else { + egui::RichText::new(display) + }; + let response = ui + .add(egui::Label::new(text).sense(egui::Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("click to copy"); + if response.clicked() { + ui.ctx().copy_text(value.to_owned()); + ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now())); + } + if flashing { + // Keep repainting so the flash clears itself a second after the click. + ui.ctx().request_repaint_after(Duration::from_millis(120)); + } +} + /// A fixed-height sparkline of a series' recent rate, with the peak (top-left) /// and current value (bottom-right) overlaid. The space is always reserved — /// even with no data — so the layout never jumps when data arrives. @@ -116,29 +154,3 @@ pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, visuals.strong_text_color(), ); } - -/// A label that copies `value` to the clipboard when clicked. -pub(crate) fn copy_label(ui: &mut egui::Ui, display: &str, value: &str) { - let response = ui - .add(egui::Label::new(display).sense(egui::Sense::click())) - .on_hover_text("click to copy"); - if response.clicked() { - ui.ctx().copy_text(value.to_owned()); - } -} - -/// A tiny clipboard button that copies `value`. -pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) { - if ui - .small_button("copy") - .on_hover_text("copy to clipboard") - .clicked() - { - ui.ctx().copy_text(value.to_owned()); - } -} - -/// The current rate of a series as a string, or a dash when unsampled. -pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String { - series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit)) -} diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index ebb7af8..5cd1784 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -10,6 +10,7 @@ use std::collections::BTreeSet; use eframe::egui; +use egui_phosphor::regular as icon; use tsunagi::ipc::{NetworkReport, StatusReport}; @@ -64,7 +65,11 @@ pub(crate) fn draw( ui.horizontal(|ui| { ui.heading("tsunagi"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("refresh").clicked() { + if ui + .button(icon::ARROWS_CLOCKWISE) + .on_hover_text("Refresh") + .clicked() + { agent.send(Command::Refresh); } if snapshot.busy { @@ -139,8 +144,8 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo ui.add_space(4.0); ui.horizontal(|ui| { ui.weak("device"); - ui.monospace(format::short(&report.endpoint_id)); - format::copy_button(ui, &report.endpoint_id); + // Clicking the shortened id copies the full one. + format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id); }); ui.horizontal(|ui| { @@ -148,7 +153,11 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo match &mut state.host_edit { None => { ui.label(&report.hostname); - if ui.small_button("edit").clicked() { + if ui + .small_button(icon::PENCIL_SIMPLE) + .on_hover_text("rename") + .clicked() + { state.host_edit = Some(report.hostname.clone()); } } @@ -156,11 +165,11 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0)); let submit = (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - || ui.small_button("save").clicked(); + || ui.small_button(icon::CHECK).on_hover_text("save").clicked(); if submit { agent.send(Command::SetHostname(buffer.trim().to_string())); state.host_edit = None; - } else if ui.small_button("cancel").clicked() { + } else if ui.small_button(icon::X).on_hover_text("cancel").clicked() { state.host_edit = None; } } @@ -186,8 +195,7 @@ fn draw_network( ui.add_space(6.0); egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - ui.strong(network.name.as_str()); - format::copy_button(ui, &network.name); + format::copy_field(ui, network.name.as_str(), &network.name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let leave = egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE)) @@ -208,20 +216,17 @@ fn draw_network( .spacing([10.0, 3.0]) .show(ui, |ui| { ui.weak("id"); - ui.horizontal(|ui| { - ui.label(format::short(&network.network_id)); - format::copy_button(ui, &network.network_id); - }); + format::copy_field(ui, &format::short(&network.network_id), &network.network_id); ui.end_row(); if let Some(range) = &network.range { ui.weak("range"); - format::copy_label(ui, range, range); + format::copy_field(ui, range, range); ui.end_row(); } if let Some(address) = address { ui.weak("addr"); - format::copy_label(ui, address, address); + format::copy_field(ui, address, address); ui.end_row(); } ui.weak("peers"); @@ -255,7 +260,7 @@ fn draw_network( state.open_devices.insert(network.network_id.clone()); } if ui - .button("Copy secret") + .button(format!("{} Copy secret", icon::COPY)) .on_hover_text("copy this network's secret to the clipboard") .clicked() { @@ -288,7 +293,7 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { .password(true), ); if ui - .button("random") + .button(icon::SHUFFLE) .on_hover_text("Generate a new random secret") .clicked() {