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 <noreply@anthropic.com>
This commit is contained in:
UltradesuandClaude Opus 4.8 committed 2026-10-02 16:00:23 +01:00
1 parent de56258eb1
commit db01c58a6f
6 files changed
+96 -61

No files matched your search

Generated
+12 -2
View File
@@ -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",
+1
View File
@@ -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.
+6
View File
@@ -31,6 +31,12 @@ impl App {
runtime: tokio::runtime::Handle,
) -> Result<Self, String> {
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 {
+17 -16
View File
@@ -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();
}
+39 -27
View File
@@ -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<Instant> = 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))
}
+21 -16
View File
@@ -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()
{