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:
1 parent
de56258eb1
commit
db01c58a6f
6 files changed
+96
-61
No files matched your search
Generated
+12
-2
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user