Add the tsunagi-tray GUI (egui) — first increment
A native, lightweight tray application to manage a running agent — no web engine. It is a thin client over the agent's local control socket, reusing tsunagi::ipc and the report types directly; it owns its own runtime, logger and event loop, like the CLI. This first increment: - tray icon with an Open/Quit menu (tray-icon); menu events are read on a background thread so Open works even while the window is hidden; - a small window that hides to the tray on close and reopens from the menu; - a background tokio worker that polls status every 2s and runs commands off the UI thread, writing a snapshot the UI renders from and repainting egui; - UI: device header, a per-network card (name, id, range, this device's address, peers connected/known, a Leave button, and the per-network active and broadcast switches), a join form (name + secret), and a transient action/error banner; an "agent unreachable" state that names the socket. Socket resolution: TSUNAGI_CONTROL_SOCKET if set, else the platform default (/var/run/tsunagi/agent.sock on macOS) — the same socket the group-access agent serves. Scope/notes: macOS first (Linux and Windows follow). Not yet done — macOS accessory activation policy (so there is no dock icon), light/dark tray icons from assets (the icon is drawn in code for now), and packaging/autostart. Builds and is clippy-clean (-D warnings) on macOS; the UI itself 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
de8e48bee7
commit
19509dc833
7 files changed
+3287
-89
No files matched your search
Generated
+2676
-89
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "tsunagi-tray"
|
||||
version = "0.1.0"
|
||||
description = "Tray application to manage a running tsunagi agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi-tray"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The agent control client and the report types: the GUI is a thin client over
|
||||
# the same local control interface the CLI uses, so it reuses these directly
|
||||
# and never re-implements the protocol.
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
# Native, lightweight GUI — no web engine. eframe re-exports egui.
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
|
||||
tray-icon = "0.21"
|
||||
# Background worker that talks to the agent off the UI thread.
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,183 @@
|
||||
//! The client side: a background worker that talks to the agent over the local
|
||||
//! control socket, off the UI thread.
|
||||
//!
|
||||
//! The UI never blocks on IO. A worker on a tokio runtime polls the agent for
|
||||
//! status on an interval and runs the commands the UI sends (join, leave,
|
||||
//! activate, broadcast), writing the latest snapshot into shared state and
|
||||
//! asking egui to repaint. The whole thing is a thin layer over
|
||||
//! [`tsunagi::ipc`]; no protocol is re-implemented here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use tsunagi::ipc::{self, StatusReport};
|
||||
|
||||
/// How often the worker refreshes status while idle.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A request from the UI to the agent.
|
||||
pub(crate) enum Command {
|
||||
/// Re-read status now.
|
||||
Refresh,
|
||||
/// Stop serving a network, or start serving it again.
|
||||
SetActive { network_id: String, active: bool },
|
||||
/// Turn local broadcast participation on or off for a network.
|
||||
SetBroadcast { network_id: String, enabled: bool },
|
||||
/// Join a network, or start one already configured.
|
||||
Join { name: String, secret: String },
|
||||
/// Leave a network entirely.
|
||||
Leave { network_id: String },
|
||||
}
|
||||
|
||||
/// The latest the worker knows, read by the UI each frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
/// The last status, or the reason it could not be read (agent down, no
|
||||
/// permission on the socket, …).
|
||||
pub status: Option<Result<StatusReport, String>>,
|
||||
/// The outcome of the most recent command, for a transient banner.
|
||||
pub last_action: Option<Result<String, String>>,
|
||||
/// Whether a command is currently being executed.
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
/// Handle the UI holds: send commands, read the latest snapshot.
|
||||
pub(crate) struct AgentClient {
|
||||
socket: PathBuf,
|
||||
tx: mpsc::UnboundedSender<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
}
|
||||
|
||||
impl AgentClient {
|
||||
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
|
||||
pub(crate) fn spawn(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
ctx: egui::Context,
|
||||
socket: PathBuf,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let shared = Arc::new(Mutex::new(Snapshot::default()));
|
||||
runtime.spawn(worker(socket.clone(), rx, Arc::clone(&shared), ctx));
|
||||
Self { socket, tx, shared }
|
||||
}
|
||||
|
||||
/// The control socket this client talks to.
|
||||
pub(crate) fn socket(&self) -> &std::path::Path {
|
||||
&self.socket
|
||||
}
|
||||
|
||||
/// Sends a command; a dropped worker simply means nothing happens.
|
||||
pub(crate) fn send(&self, command: Command) {
|
||||
let _ = self.tx.send(command);
|
||||
}
|
||||
|
||||
/// A copy of the latest snapshot.
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
lock(&self.shared).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker loop: refresh on an interval and whenever a command runs.
|
||||
async fn worker(
|
||||
socket: PathBuf,
|
||||
mut rx: mpsc::UnboundedReceiver<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
ctx: egui::Context,
|
||||
) {
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
let mut tick = tokio::time::interval(POLL_INTERVAL);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
}
|
||||
command = rx.recv() => {
|
||||
let Some(command) = command else { return };
|
||||
run(&socket, command, &shared, &ctx).await;
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads status and stores it, mapping any failure to a human string.
|
||||
async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
|
||||
let status = ipc::request_status(socket)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.status = Some(status);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Runs one command and records its outcome.
|
||||
async fn run(
|
||||
socket: &std::path::Path,
|
||||
command: Command,
|
||||
shared: &Arc<Mutex<Snapshot>>,
|
||||
ctx: &egui::Context,
|
||||
) {
|
||||
lock(shared).busy = true;
|
||||
ctx.request_repaint();
|
||||
|
||||
let outcome: Result<String, String> = match command {
|
||||
Command::Refresh => Ok(String::new()),
|
||||
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
|
||||
.await
|
||||
.map(|report| {
|
||||
let state = if report.active { "started" } else { "stopped" };
|
||||
format!("{} {}", state, report.name)
|
||||
})
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetBroadcast {
|
||||
network_id,
|
||||
enabled,
|
||||
} => ipc::set_broadcast(socket, &network_id, enabled)
|
||||
.await
|
||||
.map(|on| format!("broadcast {}", if on { "on" } else { "off" }))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
|
||||
.await
|
||||
.map(|report| format!("joined {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
|
||||
.await
|
||||
.map(|report| format!("left {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.busy = false;
|
||||
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
|
||||
None
|
||||
} else {
|
||||
Some(outcome)
|
||||
};
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where the GUI looks for the agent: `TSUNAGI_CONTROL_SOCKET` if set, else the
|
||||
/// platform default (the system service's path).
|
||||
pub(crate) fn resolve_socket() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("TSUNAGI_CONTROL_SOCKET") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
ipc::default_control_socket_path()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The eframe application: wires the tray, the window and the agent worker.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::agent::{AgentClient, resolve_socket};
|
||||
use crate::tray::{Action, Tray};
|
||||
use crate::ui::{self, JoinForm};
|
||||
|
||||
/// The tray application state.
|
||||
pub(crate) struct App {
|
||||
agent: AgentClient,
|
||||
tray: Tray,
|
||||
join: JoinForm,
|
||||
/// Set when the user chose Quit, so the next close actually exits instead
|
||||
/// of hiding to the tray.
|
||||
quitting: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Builds the app: resolves the socket, starts the worker, creates the tray.
|
||||
pub(crate) fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
) -> Result<Self, String> {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket());
|
||||
let tray = Tray::new(ctx)?;
|
||||
Ok(Self {
|
||||
agent,
|
||||
tray,
|
||||
join: JoinForm::default(),
|
||||
quitting: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for action in self.tray.poll() {
|
||||
match action {
|
||||
Action::Open => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
Action::Quit => {
|
||||
self.quitting = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the window hides it to the tray; only Quit truly exits.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||
}
|
||||
|
||||
let snapshot = self.agent.snapshot();
|
||||
ui::draw(ctx, &self.agent, &mut self.join, &snapshot);
|
||||
|
||||
// Keep ticking so fresh status and tray actions are picked up even when
|
||||
// nothing else requests a repaint.
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Tray application for managing a running tsunagi agent.
|
||||
//!
|
||||
//! A small window (networks, their peers and addresses, join/leave, per-network
|
||||
//! active and broadcast switches) plus a tray menu (Open, Quit). It is a thin
|
||||
//! client over the agent's local control socket — the same interface the CLI
|
||||
//! uses — and owns its own runtime, logger and event loop, like the CLI binary.
|
||||
|
||||
// The window is created hidden-to-tray on close rather than exiting; the binary
|
||||
// itself may exit on Quit. Nothing here is a library.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod agent;
|
||||
mod app;
|
||||
mod tray;
|
||||
mod ui;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tsunagi")
|
||||
.with_inner_size([380.0, 540.0])
|
||||
.with_min_inner_size([320.0, 360.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"tsunagi",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
App::new(cc, handle.clone())
|
||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||
.map_err(std::convert::Into::into)
|
||||
}),
|
||||
)?;
|
||||
|
||||
// The window loop has ended (Quit); let the runtime wind down.
|
||||
drop(runtime);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The tray icon and its menu.
|
||||
//!
|
||||
//! The menu is just Open and Quit; everything else (per-network switches,
|
||||
//! join/leave) lives in the window. Menu clicks are read on a background
|
||||
//! thread and turned into [`Action`]s, so they are handled even while the
|
||||
//! window is hidden and egui would otherwise not be repainting.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use eframe::egui;
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
/// What the user picked from the tray menu.
|
||||
pub(crate) enum Action {
|
||||
/// Show (and focus) the window.
|
||||
Open,
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// The live tray icon plus the channel of menu actions.
|
||||
pub(crate) struct Tray {
|
||||
/// Kept alive for as long as the app runs; dropping it removes the icon.
|
||||
_icon: TrayIcon,
|
||||
actions: mpsc::Receiver<Action>,
|
||||
}
|
||||
|
||||
impl Tray {
|
||||
/// Builds the tray and starts reading its menu events.
|
||||
///
|
||||
/// Created on the main thread (required on macOS); `ctx` is used to wake the
|
||||
/// UI when a menu item is chosen.
|
||||
pub(crate) fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let open = MenuItem::new("Open tsunagi", true, None);
|
||||
let quit = MenuItem::new("Quit", true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append(&open).map_err(|err| err.to_string())?;
|
||||
menu.append(&PredefinedMenuItem::separator())
|
||||
.map_err(|err| err.to_string())?;
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = make_icon()?;
|
||||
let tray = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(icon)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let open_id = open.id().clone();
|
||||
let quit_id = quit.id().clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
// The global menu-event channel is fed by the OS event loop.
|
||||
while let Ok(event) = MenuEvent::receiver().recv() {
|
||||
let action = if event.id == open_id {
|
||||
Action::Open
|
||||
} else if event.id == quit_id {
|
||||
Action::Quit
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if tx.send(action).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_icon: tray,
|
||||
actions: rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// The menu actions since the last call.
|
||||
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
|
||||
self.actions.try_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// A small round icon, drawn in code so there is no asset to ship yet.
|
||||
fn make_icon() -> Result<Icon, String> {
|
||||
const SIZE: u32 = 32;
|
||||
let centre = (SIZE as f32 - 1.0) / 2.0;
|
||||
let radius = SIZE as f32 * 0.46;
|
||||
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
rgba[i] = 0x2f;
|
||||
rgba[i + 1] = 0x80;
|
||||
rgba[i + 2] = 0xd8;
|
||||
rgba[i + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The window contents, drawn from the latest agent snapshot.
|
||||
//!
|
||||
//! Pure rendering: the current state comes from the snapshot, and a change to a
|
||||
//! switch or a button sends a [`Command`] to the worker. egui is immediate
|
||||
//! mode, so the switches reflect the agent's state and only a user change
|
||||
//! dispatches a command.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||
|
||||
use crate::agent::{AgentClient, Command, Snapshot};
|
||||
|
||||
/// Join form state, kept across frames.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct JoinForm {
|
||||
pub(crate) name: String,
|
||||
pub(crate) secret: String,
|
||||
}
|
||||
|
||||
/// Draws the whole window.
|
||||
pub(crate) fn draw(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
join: &mut JoinForm,
|
||||
snapshot: &Snapshot,
|
||||
) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("⟳").on_hover_text("Refresh").clicked() {
|
||||
agent.send(Command::Refresh);
|
||||
}
|
||||
if snapshot.busy {
|
||||
ui.add(egui::Spinner::new());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
match &snapshot.status {
|
||||
None => {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Connecting to the agent…");
|
||||
}
|
||||
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
||||
Some(Ok(report)) => {
|
||||
draw_header(ui, report);
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if report.networks.is_empty() {
|
||||
ui.label("No networks yet. Join one below.");
|
||||
}
|
||||
for network in &report.networks {
|
||||
draw_network(ui, agent, network);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
draw_join(ui, agent, join);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action) = &snapshot.last_action {
|
||||
ui.add_space(6.0);
|
||||
match action {
|
||||
Ok(message) => ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), message),
|
||||
Err(error) => ui.colored_label(egui::Color32::from_rgb(0xd0, 0x4a, 0x3c), error),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The "agent cannot be reached" state, with the socket it tried.
|
||||
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
|
||||
ui.add_space(8.0);
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd0, 0x4a, 0x3c),
|
||||
"The agent is not reachable.",
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!("socket: {}", agent.socket().display()));
|
||||
ui.label(error);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
"Start the agent, or check that this user may open its control socket \
|
||||
(its group, mode 0660).",
|
||||
);
|
||||
}
|
||||
|
||||
/// The device line: id, hostname, cache health.
|
||||
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!("device {}", short(&report.endpoint_id)));
|
||||
ui.label(format!("host {}", report.hostname));
|
||||
if !report.cache_healthy {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
|
||||
"cache unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One network: name, range, this device's address, switches, peers, leave.
|
||||
fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) {
|
||||
ui.add_space(6.0);
|
||||
ui.group(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong(network.name.as_str());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Leave").clicked() {
|
||||
agent.send(Command::Leave {
|
||||
network_id: network.network_id.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(format!("id {}", short(&network.network_id)));
|
||||
if let Some(range) = &network.range {
|
||||
ui.label(format!("range {range}"));
|
||||
}
|
||||
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
|
||||
ui.label(format!("addr {address}"));
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let mut active = network.active;
|
||||
if ui.checkbox(&mut active, "active").changed() {
|
||||
agent.send(Command::SetActive {
|
||||
network_id: network.network_id.clone(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
let mut broadcast = network.broadcast;
|
||||
if ui.checkbox(&mut broadcast, "broadcast").changed() {
|
||||
agent.send(Command::SetBroadcast {
|
||||
network_id: network.network_id.clone(),
|
||||
enabled: broadcast,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let connected = network.peers.len();
|
||||
let known = network.members.len();
|
||||
ui.label(format!("peers {connected} connected · {known} known"));
|
||||
});
|
||||
}
|
||||
|
||||
/// The join form: name + secret + button.
|
||||
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
||||
ui.add_space(4.0);
|
||||
ui.label("Join a network");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("name");
|
||||
ui.text_edit_singleline(&mut join.name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("secret");
|
||||
ui.add(egui::TextEdit::singleline(&mut join.secret).password(true));
|
||||
});
|
||||
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
|
||||
if ui.add_enabled(ready, egui::Button::new("Join")).clicked() {
|
||||
agent.send(Command::Join {
|
||||
name: join.name.trim().to_string(),
|
||||
secret: join.secret.trim().to_string(),
|
||||
});
|
||||
join.secret.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A short, readable prefix of a long identifier (char-safe, no panic).
|
||||
fn short(id: &str) -> String {
|
||||
const KEEP: usize = 12;
|
||||
if id.chars().count() <= KEEP {
|
||||
id.to_string()
|
||||
} else {
|
||||
format!("{}…", id.chars().take(KEEP).collect::<String>())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user