Reworked statistics
This commit is contained in:
Generated
+1
-1
@@ -1567,7 +1567,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
@@ -147,12 +147,16 @@ pub enum AppEvent {
|
|||||||
},
|
},
|
||||||
/// This peer's connection ticket, requested from the Federation tab.
|
/// This peer's connection ticket, requested from the Federation tab.
|
||||||
FedTicket(Result<String, String>),
|
FedTicket(Result<String, String>),
|
||||||
|
/// Immediate library publish finished.
|
||||||
|
FedSyncFinished(String),
|
||||||
/// Fresh personal-device sync status snapshot for Settings.
|
/// Fresh personal-device sync status snapshot for Settings.
|
||||||
DeviceSyncStatus(crate::devices::DeviceSyncStatus),
|
DeviceSyncStatus(crate::devices::DeviceSyncStatus),
|
||||||
/// Invite link for pairing another device.
|
/// Invite link for pairing another device.
|
||||||
DeviceInvite(Result<String, String>),
|
DeviceInvite(Result<String, String>),
|
||||||
/// Result of `:connect frid://i/...`.
|
/// Result of `:connect frid://i/...`.
|
||||||
DeviceConnectResult(Result<String, String>),
|
DeviceConnectResult(Result<String, String>),
|
||||||
|
/// Manual trusted-device sync finished.
|
||||||
|
DeviceSyncFinished(String),
|
||||||
/// Incoming pairing request that passed the invite-secret check.
|
/// Incoming pairing request that passed the invite-secret check.
|
||||||
DevicePairingRequest(crate::devices::PendingPairing),
|
DevicePairingRequest(crate::devices::PendingPairing),
|
||||||
/// Trusted device playback state, delivered by personal-device sync.
|
/// Trusted device playback state, delivered by personal-device sync.
|
||||||
|
|||||||
+23
-52
@@ -7,9 +7,9 @@ pub(crate) mod popup;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
use std::io::{self, Write as _};
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -1397,6 +1397,8 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
}
|
}
|
||||||
Effect::FedApplySettings => fed_apply_settings(state, runtime),
|
Effect::FedApplySettings => fed_apply_settings(state, runtime),
|
||||||
Effect::FedSyncNow => {
|
Effect::FedSyncNow => {
|
||||||
|
state.federation.publishing = true;
|
||||||
|
state.status_message = Some("publishing library…".to_string());
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1405,7 +1407,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
Err(err) => format!("federation sync failed: {err:#}"),
|
Err(err) => format!("federation sync failed: {err:#}"),
|
||||||
};
|
};
|
||||||
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
||||||
let _ = tx.send(AppEvent::StatusMessage(message));
|
let _ = tx.send(AppEvent::FedSyncFinished(message));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Effect::FedShowTicket => {
|
Effect::FedShowTicket => {
|
||||||
@@ -1438,6 +1440,8 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
}
|
}
|
||||||
Effect::DeviceConnectInvite(invite) => device_connect(runtime, invite),
|
Effect::DeviceConnectInvite(invite) => device_connect(runtime, invite),
|
||||||
Effect::DeviceSyncNow => {
|
Effect::DeviceSyncNow => {
|
||||||
|
state.federation.device_syncing = true;
|
||||||
|
state.status_message = Some("syncing devices…".to_string());
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let devices = Arc::clone(&runtime.devices);
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
@@ -1447,7 +1451,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
Err(err) => format!("devices: {err:#}"),
|
Err(err) => format!("devices: {err:#}"),
|
||||||
};
|
};
|
||||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||||
let _ = tx.send(AppEvent::StatusMessage(message));
|
let _ = tx.send(AppEvent::DeviceSyncFinished(message));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Effect::DeviceSetName(name) => {
|
Effect::DeviceSetName(name) => {
|
||||||
@@ -1667,45 +1671,6 @@ fn shell_quote(path: &Path) -> String {
|
|||||||
format!("'{}'", value.replace('\'', "'\\''"))
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_text_to_clipboard(text: &str) -> bool {
|
|
||||||
let command: &[&str] = if cfg!(target_os = "macos") {
|
|
||||||
&["pbcopy"]
|
|
||||||
} else if cfg!(target_os = "windows") {
|
|
||||||
&["clip"]
|
|
||||||
} else {
|
|
||||||
&["wl-copy"]
|
|
||||||
};
|
|
||||||
let Some((program, args)) = command.split_first() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let mut child = match Command::new(program)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(child) => child,
|
|
||||||
Err(_) if !cfg!(target_os = "macos") && !cfg!(target_os = "windows") => {
|
|
||||||
match Command::new("xclip")
|
|
||||||
.args(["-selection", "clipboard"])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(child) => child,
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
let Some(mut stdin) = child.stdin.take() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if stdin.write_all(text.as_bytes()).is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
drop(stdin);
|
|
||||||
child.wait().is_ok_and(|status| status.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start playing `queue[queue_pos]`: open the local file in a background
|
/// Start playing `queue[queue_pos]`: open the local file in a background
|
||||||
/// task and hand the reader to the audio thread.
|
/// task and hand the reader to the audio thread.
|
||||||
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
|
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
|
||||||
@@ -2826,18 +2791,22 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
clamp_settings_cursor(state);
|
clamp_settings_cursor(state);
|
||||||
}
|
}
|
||||||
|
AppEvent::FedSyncFinished(message) => {
|
||||||
|
state.federation.publishing = false;
|
||||||
|
state.status_message = Some(message);
|
||||||
|
}
|
||||||
|
AppEvent::DeviceSyncFinished(message) => {
|
||||||
|
state.federation.device_syncing = false;
|
||||||
|
state.status_message = Some(message);
|
||||||
|
}
|
||||||
AppEvent::DeviceInvite(result) => match result {
|
AppEvent::DeviceInvite(result) => match result {
|
||||||
Ok(invite) => {
|
Ok(invite) => {
|
||||||
let copied = copy_text_to_clipboard(&invite);
|
state.popup = Some(state::Popup::FedCopyText {
|
||||||
state.popup = Some(state::Popup::FedText {
|
|
||||||
title: "Device invite".to_string(),
|
title: "Device invite".to_string(),
|
||||||
text: invite,
|
text: invite,
|
||||||
|
help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(),
|
||||||
});
|
});
|
||||||
state.status_message = Some(if copied {
|
state.status_message = Some("device invite generated".to_string());
|
||||||
"device invite copied to clipboard".to_string()
|
|
||||||
} else {
|
|
||||||
"device invite generated".to_string()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(message) => state.status_message = Some(format!("device invite: {message}")),
|
Err(message) => state.status_message = Some(format!("device invite: {message}")),
|
||||||
},
|
},
|
||||||
@@ -3105,9 +3074,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
AppEvent::FedTicket(result) => match result {
|
AppEvent::FedTicket(result) => match result {
|
||||||
Ok(ticket) => {
|
Ok(ticket) => {
|
||||||
state.popup = Some(state::Popup::FedText {
|
state.popup = Some(state::Popup::FedCopyText {
|
||||||
title: "Federation ticket (share with a peer)".to_string(),
|
title: "Connection ticket".to_string(),
|
||||||
text: ticket,
|
text: ticket,
|
||||||
|
help: "Copy this ticket and paste it into Connect to a peer on another client."
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(message) => state.status_message = Some(message),
|
Err(message) => state.status_message = Some(message),
|
||||||
|
|||||||
+183
-9
@@ -13,7 +13,7 @@ use crate::app::Runtime;
|
|||||||
use crate::app::event::AppEvent;
|
use crate::app::event::AppEvent;
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
self, AppState, DeleteTarget, DevicePresenceSection, EditField, EditTarget, FedInputField,
|
self, AppState, DeleteTarget, DevicePresenceSection, EditField, EditTarget, FedInputField,
|
||||||
Loadable, Popup, addable_playlists,
|
FederationStatusPopupState, Loadable, Popup, StatusDetailFocus, addable_playlists,
|
||||||
};
|
};
|
||||||
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
|
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
|
||||||
|
|
||||||
@@ -136,8 +136,40 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
_ => state.popup = Some(Popup::FedText { title, text }),
|
||||||
},
|
},
|
||||||
Popup::FederationStatusDetails { scroll } => {
|
Popup::FedCopyText { title, text, help } => match key.code {
|
||||||
handle_federation_status_details(state, scroll, key);
|
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||||
|
KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) {
|
||||||
|
Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
|
||||||
|
Err(err) => {
|
||||||
|
state.status_message = Some(format!("copy failed: {err}"));
|
||||||
|
state.popup = Some(Popup::FedCopyText { title, text, help });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => state.popup = Some(Popup::FedCopyText { title, text, help }),
|
||||||
|
},
|
||||||
|
Popup::FederationStatusDetails {
|
||||||
|
focus,
|
||||||
|
status_cursor,
|
||||||
|
devices_scroll,
|
||||||
|
logs_scroll,
|
||||||
|
} => handle_federation_status_details(
|
||||||
|
state,
|
||||||
|
FederationStatusPopupState {
|
||||||
|
focus,
|
||||||
|
status_cursor,
|
||||||
|
devices_scroll,
|
||||||
|
logs_scroll,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
Popup::FederationStatusText {
|
||||||
|
parent,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
scroll,
|
||||||
|
} => handle_federation_status_child(state, parent, title, text, scroll, key),
|
||||||
|
Popup::FederationStatusLog { parent, scroll } => {
|
||||||
|
handle_federation_status_log(state, parent, scroll, key);
|
||||||
}
|
}
|
||||||
Popup::DevicePairing {
|
Popup::DevicePairing {
|
||||||
request_id,
|
request_id,
|
||||||
@@ -166,20 +198,162 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_federation_status_details(state: &mut AppState, scroll: usize, key: KeyEvent) {
|
fn handle_federation_status_details(
|
||||||
|
state: &mut AppState,
|
||||||
|
mut parent: FederationStatusPopupState,
|
||||||
|
key: KeyEvent,
|
||||||
|
) {
|
||||||
|
let action_count = status_detail_action_count(state);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||||
|
KeyCode::Left | KeyCode::Char('h') => parent.focus = parent.focus.previous(),
|
||||||
|
KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => parent.focus = parent.focus.next(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => match parent.focus {
|
||||||
|
StatusDetailFocus::Status => {
|
||||||
|
parent.status_cursor = parent.status_cursor.saturating_sub(1)
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Devices => {
|
||||||
|
parent.devices_scroll = parent.devices_scroll.saturating_sub(1)
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Logs => {
|
||||||
|
if action_count > 0 {
|
||||||
|
parent.focus = StatusDetailFocus::Status;
|
||||||
|
parent.status_cursor = action_count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => match parent.focus {
|
||||||
|
StatusDetailFocus::Status => {
|
||||||
|
if parent.status_cursor + 1 < action_count {
|
||||||
|
parent.status_cursor += 1;
|
||||||
|
} else {
|
||||||
|
parent.focus = StatusDetailFocus::Logs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Devices => {
|
||||||
|
parent.devices_scroll = parent.devices_scroll.saturating_add(1)
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Logs => {}
|
||||||
|
},
|
||||||
|
KeyCode::PageUp => match parent.focus {
|
||||||
|
StatusDetailFocus::Status => parent.status_cursor = 0,
|
||||||
|
StatusDetailFocus::Devices => {
|
||||||
|
parent.devices_scroll = parent.devices_scroll.saturating_sub(8)
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Logs => {}
|
||||||
|
},
|
||||||
|
KeyCode::PageDown => match parent.focus {
|
||||||
|
StatusDetailFocus::Status => {
|
||||||
|
parent.status_cursor = action_count.saturating_sub(1);
|
||||||
|
parent.focus = StatusDetailFocus::Logs;
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Devices => {
|
||||||
|
parent.devices_scroll = parent.devices_scroll.saturating_add(8)
|
||||||
|
}
|
||||||
|
StatusDetailFocus::Logs => {}
|
||||||
|
},
|
||||||
|
KeyCode::Enter if parent.focus == StatusDetailFocus::Status => {
|
||||||
|
if let Some((title, text)) = status_detail_action_text(state, parent.status_cursor) {
|
||||||
|
state.popup = Some(Popup::FederationStatusText {
|
||||||
|
parent,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
scroll: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter if parent.focus == StatusDetailFocus::Logs => {
|
||||||
|
state.popup = Some(Popup::FederationStatusLog { parent, scroll: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
parent.status_cursor = parent.status_cursor.min(action_count.saturating_sub(1));
|
||||||
|
state.popup = Some(parent.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_federation_status_child(
|
||||||
|
state: &mut AppState,
|
||||||
|
parent: FederationStatusPopupState,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
scroll: usize,
|
||||||
|
key: KeyEvent,
|
||||||
|
) {
|
||||||
let next_scroll = match key.code {
|
let next_scroll = match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => return,
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
|
||||||
|
state.popup = Some(parent.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
|
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
|
||||||
KeyCode::Down | KeyCode::Char('j') => scroll + 1,
|
KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1),
|
||||||
KeyCode::PageUp => scroll.saturating_sub(8),
|
KeyCode::PageUp => scroll.saturating_sub(10),
|
||||||
KeyCode::PageDown => scroll + 8,
|
KeyCode::PageDown => scroll.saturating_add(10),
|
||||||
_ => scroll,
|
_ => scroll,
|
||||||
};
|
};
|
||||||
state.popup = Some(Popup::FederationStatusDetails {
|
state.popup = Some(Popup::FederationStatusText {
|
||||||
|
parent,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
scroll: next_scroll,
|
scroll: next_scroll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_federation_status_log(
|
||||||
|
state: &mut AppState,
|
||||||
|
parent: FederationStatusPopupState,
|
||||||
|
scroll: usize,
|
||||||
|
key: KeyEvent,
|
||||||
|
) {
|
||||||
|
let next_scroll = match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
state.popup = Some(parent.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1),
|
||||||
|
KeyCode::PageUp => scroll.saturating_sub(10),
|
||||||
|
KeyCode::PageDown => scroll.saturating_add(10),
|
||||||
|
_ => scroll,
|
||||||
|
};
|
||||||
|
state.popup = Some(Popup::FederationStatusLog {
|
||||||
|
parent,
|
||||||
|
scroll: next_scroll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_detail_action_count(state: &AppState) -> usize {
|
||||||
|
state
|
||||||
|
.federation
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.filter(|status| status.running)
|
||||||
|
.map(|status| 1 + usize::from(!status.connected_peers.is_empty()))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_detail_action_text(state: &AppState, cursor: usize) -> Option<(String, String)> {
|
||||||
|
let status = state
|
||||||
|
.federation
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.filter(|status| status.running)?;
|
||||||
|
match cursor {
|
||||||
|
0 => Some((
|
||||||
|
"Endpoint IDs".to_string(),
|
||||||
|
format!(
|
||||||
|
"Endpoint ID\n{}\n\nDHT node ID\n{}",
|
||||||
|
status.endpoint_id, status.dht_node_id
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
1 if !status.connected_peers.is_empty() => {
|
||||||
|
Some(("Peer IDs".to_string(), status.connected_peers.join("\n")))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_connected_devices(
|
fn handle_connected_devices(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &mut Runtime,
|
runtime: &mut Runtime,
|
||||||
|
|||||||
+87
-1
@@ -711,6 +711,12 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Wrapped read-only text (this peer's connection ticket).
|
/// Wrapped read-only text (this peer's connection ticket).
|
||||||
FedText { title: String, text: String },
|
FedText { title: String, text: String },
|
||||||
|
/// Wrapped text that can be copied to the system clipboard.
|
||||||
|
FedCopyText {
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
help: String,
|
||||||
|
},
|
||||||
/// Incoming trusted-device pairing request.
|
/// Incoming trusted-device pairing request.
|
||||||
DevicePairing {
|
DevicePairing {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -725,7 +731,68 @@ pub enum Popup {
|
|||||||
/// Connected playback devices and their current role/status.
|
/// Connected playback devices and their current role/status.
|
||||||
ConnectedDevices { cursor: usize },
|
ConnectedDevices { cursor: usize },
|
||||||
/// Full federation, transport and device status details.
|
/// Full federation, transport and device status details.
|
||||||
FederationStatusDetails { scroll: usize },
|
FederationStatusDetails {
|
||||||
|
focus: StatusDetailFocus,
|
||||||
|
status_cursor: usize,
|
||||||
|
devices_scroll: usize,
|
||||||
|
logs_scroll: usize,
|
||||||
|
},
|
||||||
|
/// Full text opened from the full federation status dashboard.
|
||||||
|
FederationStatusText {
|
||||||
|
parent: FederationStatusPopupState,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
scroll: usize,
|
||||||
|
},
|
||||||
|
/// Full connection log opened from the full federation status dashboard.
|
||||||
|
FederationStatusLog {
|
||||||
|
parent: FederationStatusPopupState,
|
||||||
|
scroll: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FederationStatusPopupState {
|
||||||
|
pub focus: StatusDetailFocus,
|
||||||
|
pub status_cursor: usize,
|
||||||
|
pub devices_scroll: usize,
|
||||||
|
pub logs_scroll: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StatusDetailFocus {
|
||||||
|
Status,
|
||||||
|
Devices,
|
||||||
|
Logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FederationStatusPopupState> for Popup {
|
||||||
|
fn from(value: FederationStatusPopupState) -> Self {
|
||||||
|
Popup::FederationStatusDetails {
|
||||||
|
focus: value.focus,
|
||||||
|
status_cursor: value.status_cursor,
|
||||||
|
devices_scroll: value.devices_scroll,
|
||||||
|
logs_scroll: value.logs_scroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusDetailFocus {
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
StatusDetailFocus::Status => StatusDetailFocus::Devices,
|
||||||
|
StatusDetailFocus::Devices => StatusDetailFocus::Logs,
|
||||||
|
StatusDetailFocus::Logs => StatusDetailFocus::Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous(self) -> Self {
|
||||||
|
match self {
|
||||||
|
StatusDetailFocus::Status => StatusDetailFocus::Logs,
|
||||||
|
StatusDetailFocus::Devices => StatusDetailFocus::Status,
|
||||||
|
StatusDetailFocus::Logs => StatusDetailFocus::Devices,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -745,6 +812,23 @@ impl FedInputField {
|
|||||||
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn help(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FedInputField::NetworkId => {
|
||||||
|
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
||||||
|
}
|
||||||
|
FedInputField::ConnectTicket => {
|
||||||
|
"Paste a connection ticket generated by another client to connect to that peer directly."
|
||||||
|
}
|
||||||
|
FedInputField::DeviceName => {
|
||||||
|
"A friendly nickname for this device, used only to make connected-device management easier."
|
||||||
|
}
|
||||||
|
FedInputField::ConnectInvite => {
|
||||||
|
"Paste a frid:// invite generated by another client to add this device to its sync group."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows of the federation block inside Settings, in display order.
|
/// Rows of the federation block inside Settings, in display order.
|
||||||
@@ -944,6 +1028,8 @@ pub struct FederationTab {
|
|||||||
pub settings: crate::federation::FedSettings,
|
pub settings: crate::federation::FedSettings,
|
||||||
pub status: Option<crate::federation::FedStatus>,
|
pub status: Option<crate::federation::FedStatus>,
|
||||||
pub devices: Option<crate::devices::DeviceSyncStatus>,
|
pub devices: Option<crate::devices::DeviceSyncStatus>,
|
||||||
|
pub publishing: bool,
|
||||||
|
pub device_syncing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||||
|
|||||||
+6
-1
@@ -2579,7 +2579,12 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
SettingsRow::StatusDetails => {
|
SettingsRow::StatusDetails => {
|
||||||
state.popup = Some(Popup::FederationStatusDetails { scroll: 0 });
|
state.popup = Some(Popup::FederationStatusDetails {
|
||||||
|
focus: super::state::StatusDetailFocus::Status,
|
||||||
|
status_cursor: 0,
|
||||||
|
devices_scroll: 0,
|
||||||
|
logs_scroll: 0,
|
||||||
|
});
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
SettingsRow::DeviceName => {
|
SettingsRow::DeviceName => {
|
||||||
|
|||||||
+294
-178
@@ -73,7 +73,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let (label, value) = match row {
|
let (label, value) = match row {
|
||||||
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
|
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
|
||||||
FedRow::NetworkId => (
|
FedRow::NetworkId => (
|
||||||
"Network ID (shared secret)",
|
"Network ID",
|
||||||
if settings.network_id.is_empty() {
|
if settings.network_id.is_empty() {
|
||||||
"(not set — press enter)".to_string()
|
"(not set — press enter)".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -84,7 +84,14 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
"Save federated tracks to the library on listen",
|
"Save federated tracks to the library on listen",
|
||||||
on_off(settings.save_on_listen).to_string(),
|
on_off(settings.save_on_listen).to_string(),
|
||||||
),
|
),
|
||||||
FedRow::SyncNow => ("Publish the library now", "↵".to_string()),
|
FedRow::SyncNow => (
|
||||||
|
"Publish the library now",
|
||||||
|
if state.federation.publishing {
|
||||||
|
format!("{} publishing", state.spinner())
|
||||||
|
} else {
|
||||||
|
"↵".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()),
|
FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()),
|
||||||
FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()),
|
FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()),
|
||||||
};
|
};
|
||||||
@@ -160,7 +167,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
"Sync devices now",
|
"Sync devices now",
|
||||||
if connected_devices_enabled {
|
if connected_devices_enabled {
|
||||||
"↵".to_string()
|
if state.federation.device_syncing {
|
||||||
|
format!("{} syncing", state.spinner())
|
||||||
|
} else {
|
||||||
|
"↵".to_string()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
disabled_value.clone()
|
disabled_value.clone()
|
||||||
},
|
},
|
||||||
@@ -428,107 +439,6 @@ fn short_id(id: &str) -> String {
|
|||||||
id.chars().take(12).collect::<String>() + "…"
|
id.chars().take(12).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_transport_status(lines: &mut Vec<Line<'static>>, status: &crate::federation::FedStatus) {
|
|
||||||
lines.push(Line::default());
|
|
||||||
lines.push(Line::styled("Iroh Transport", theme::header()));
|
|
||||||
let transport = &status.transport;
|
|
||||||
if transport.total_samples == 0 {
|
|
||||||
lines.push(status_line("Streams", "no samples yet".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let runtime_total = transport
|
|
||||||
.runtime_tx_bytes
|
|
||||||
.saturating_add(transport.runtime_rx_bytes);
|
|
||||||
lines.push(status_line(
|
|
||||||
"Runtime traffic",
|
|
||||||
format!(
|
|
||||||
"{} · tx {} · rx {} · active {}",
|
|
||||||
short_bytes_label(runtime_total),
|
|
||||||
short_bytes_label(transport.runtime_tx_bytes),
|
|
||||||
short_bytes_label(transport.runtime_rx_bytes),
|
|
||||||
transport.active_streams
|
|
||||||
),
|
|
||||||
));
|
|
||||||
if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 {
|
|
||||||
lines.push(status_line(
|
|
||||||
"Runtime loss",
|
|
||||||
format!(
|
|
||||||
"{} pkts · {}",
|
|
||||||
transport.runtime_lost_packets,
|
|
||||||
short_bytes_label(transport.runtime_lost_bytes)
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
lines.push(status_line(
|
|
||||||
"Samples",
|
|
||||||
format!(
|
|
||||||
"{} total · direct {} · relay {} · custom {} · unknown {}",
|
|
||||||
transport.total_samples,
|
|
||||||
transport.direct_samples,
|
|
||||||
transport.relay_samples,
|
|
||||||
transport.custom_samples,
|
|
||||||
transport.unknown_samples
|
|
||||||
),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Protocols",
|
|
||||||
format!(
|
|
||||||
"audio {} · catalog {} · sync {}",
|
|
||||||
transport.audio_samples, transport.catalog_samples, transport.sync_samples
|
|
||||||
),
|
|
||||||
));
|
|
||||||
if let Some(sample) = transport.last.first() {
|
|
||||||
lines.push(status_line(
|
|
||||||
"Last stream",
|
|
||||||
format!(
|
|
||||||
"{} {} {} · {} · {}",
|
|
||||||
sample.protocol,
|
|
||||||
sample.direction,
|
|
||||||
sample.phase,
|
|
||||||
sample.selected_path,
|
|
||||||
rtt_label(sample.selected_rtt_ms)
|
|
||||||
),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Last peer",
|
|
||||||
format!(
|
|
||||||
"{} · paths d/r/c/open {}/{}/{}/{}",
|
|
||||||
sample.peer_id,
|
|
||||||
sample.direct_paths,
|
|
||||||
sample.relay_paths,
|
|
||||||
sample.custom_paths,
|
|
||||||
sample.open_paths
|
|
||||||
),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Last bytes",
|
|
||||||
format!(
|
|
||||||
"sel {}/{} · total {}/{} · lost {} / {}",
|
|
||||||
short_bytes_label(sample.selected_tx_bytes),
|
|
||||||
short_bytes_label(sample.selected_rx_bytes),
|
|
||||||
short_bytes_label(sample.total_tx_bytes),
|
|
||||||
short_bytes_label(sample.total_rx_bytes),
|
|
||||||
sample.lost_packets,
|
|
||||||
short_bytes_label(sample.lost_bytes)
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for sample in &transport.last {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(format!("{:<14}", sample.at), theme::dim()),
|
|
||||||
Span::raw(format!(
|
|
||||||
"{} {} {} · {} · tx {} rx {}",
|
|
||||||
sample.protocol,
|
|
||||||
sample.direction,
|
|
||||||
sample.phase,
|
|
||||||
sample.selected_path,
|
|
||||||
short_bytes_label(sample.total_tx_bytes),
|
|
||||||
short_bytes_label(sample.total_rx_bytes)
|
|
||||||
)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -790,65 +700,91 @@ fn first_line(value: &str) -> String {
|
|||||||
value.lines().next().unwrap_or(value).to_string()
|
value.lines().next().unwrap_or(value).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
|
pub(super) struct StatusDetailSections {
|
||||||
|
pub status: Vec<Line<'static>>,
|
||||||
|
pub devices: Vec<Line<'static>>,
|
||||||
|
pub logs: Vec<Line<'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn status_detail_sections(
|
||||||
|
state: &AppState,
|
||||||
|
status_cursor: usize,
|
||||||
|
) -> StatusDetailSections {
|
||||||
|
StatusDetailSections {
|
||||||
|
status: status_detail_status_lines(state, status_cursor),
|
||||||
|
devices: status_detail_device_lines(state),
|
||||||
|
logs: status_detail_transport_logs(state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec<Line<'static>> {
|
||||||
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
||||||
match &state.federation.status {
|
match &state.federation.status {
|
||||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||||
Some(status) if !status.running => {
|
Some(status) if !status.running => {
|
||||||
lines.push(status_line("Node", "stopped".to_string()));
|
lines.push(status_line("Node", "stopped".to_string()));
|
||||||
if let Some(error) = &status.last_error {
|
if let Some(error) = &status.last_error {
|
||||||
lines.push(status_line("Error", error.clone()));
|
lines.push(status_line("Error", first_line(error)));
|
||||||
}
|
}
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
lines.push(Line::styled(
|
lines.push(Line::styled(
|
||||||
"Enable federation and set a network id — every instance using the",
|
"Set the same Network ID on each client.",
|
||||||
theme::dim(),
|
|
||||||
));
|
|
||||||
lines.push(Line::styled(
|
|
||||||
"same id (furumi TUI or furumi-fd) finds the others automatically.",
|
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(status) => {
|
Some(status) => {
|
||||||
lines.push(status_line("Node", format!("running · {}", status.network)));
|
lines.push(status_line("Node", format!("running · {}", status.network)));
|
||||||
lines.push(status_line(
|
lines.push(status_action_line(
|
||||||
"Endpoint",
|
"Endpoint",
|
||||||
format!("{} · dht {}", status.endpoint_id, status.dht_node_id),
|
|
||||||
));
|
|
||||||
let peers = if status.connected_peers.is_empty() {
|
|
||||||
format!("none · contacts {}", status.known_contacts)
|
|
||||||
} else {
|
|
||||||
let names: Vec<String> = status.connected_peers.iter().map(String::clone).collect();
|
|
||||||
let more = status.connected_peers.len().saturating_sub(names.len());
|
|
||||||
let more = if more > 0 {
|
|
||||||
format!(" +{more}")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
format!(
|
format!(
|
||||||
"{} connected{} · contacts {} · {}",
|
"{} · dht {}",
|
||||||
|
short_id(&status.endpoint_id),
|
||||||
|
short_id(&status.dht_node_id)
|
||||||
|
),
|
||||||
|
status_cursor == 0,
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Peers",
|
||||||
|
format!(
|
||||||
|
"{} connected · {} contacts",
|
||||||
status.connected_peers.len(),
|
status.connected_peers.len(),
|
||||||
more,
|
status.known_contacts
|
||||||
status.known_contacts,
|
),
|
||||||
names.join(", ")
|
));
|
||||||
)
|
if !status.connected_peers.is_empty() {
|
||||||
};
|
let mut peers: Vec<String> = status
|
||||||
lines.push(status_line("Peers", peers));
|
.connected_peers
|
||||||
|
.iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|peer| short_id(peer))
|
||||||
|
.collect();
|
||||||
|
if status.connected_peers.len() > peers.len() {
|
||||||
|
peers.push(format!("+{}", status.connected_peers.len() - peers.len()));
|
||||||
|
}
|
||||||
|
lines.push(status_action_line(
|
||||||
|
"Peer IDs",
|
||||||
|
peers.join(", "),
|
||||||
|
status_cursor == 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"DHT",
|
"DHT",
|
||||||
format!(
|
format!(
|
||||||
"{} records · {} · {} published",
|
"{} records · {}",
|
||||||
status
|
status
|
||||||
.stored_dht_records
|
.stored_dht_records
|
||||||
.map(|count| count.to_string())
|
.map(|count| count.to_string())
|
||||||
.unwrap_or_else(|| "unavailable".to_string()),
|
.unwrap_or_else(|| "n/a".to_string()),
|
||||||
status
|
status
|
||||||
.stored_dht_bytes
|
.stored_dht_bytes
|
||||||
.map(short_bytes_label)
|
.map(short_bytes_label)
|
||||||
.unwrap_or_else(|| "unavailable".to_string()),
|
.unwrap_or_else(|| "n/a".to_string())
|
||||||
status.published_items
|
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Published",
|
||||||
|
format!("{} items", status.published_items),
|
||||||
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Last sync",
|
"Last sync",
|
||||||
status
|
status
|
||||||
@@ -857,13 +793,148 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
.unwrap_or_else(|| "not yet".to_string()),
|
.unwrap_or_else(|| "not yet".to_string()),
|
||||||
));
|
));
|
||||||
if let Some(error) = &status.last_error {
|
if let Some(error) = &status.last_error {
|
||||||
lines.push(status_line("Error", error.clone()));
|
lines.push(status_line("Error", first_line(error)));
|
||||||
}
|
}
|
||||||
push_transport_status(&mut lines, status);
|
push_transport_summary_status(&mut lines, status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_action_line(label: &str, value: String, selected: bool) -> Line<'static> {
|
||||||
|
let marker = if selected { "▶" } else { " " };
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{marker} {label:<16}"),
|
||||||
|
if selected {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(value),
|
||||||
|
Span::styled(" ↵", theme::dim()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_transport_summary_status(
|
||||||
|
lines: &mut Vec<Line<'static>>,
|
||||||
|
status: &crate::federation::FedStatus,
|
||||||
|
) {
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
lines.push(Line::styled("Connected Devices", theme::header()));
|
lines.push(Line::styled("Iroh Transport", theme::header()));
|
||||||
|
let transport = &status.transport;
|
||||||
|
let runtime_total = transport
|
||||||
|
.runtime_tx_bytes
|
||||||
|
.saturating_add(transport.runtime_rx_bytes);
|
||||||
|
lines.push(status_line(
|
||||||
|
"Traffic",
|
||||||
|
format!(
|
||||||
|
"{} · tx {} · rx {}",
|
||||||
|
short_bytes_label(runtime_total),
|
||||||
|
short_bytes_label(transport.runtime_tx_bytes),
|
||||||
|
short_bytes_label(transport.runtime_rx_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Active",
|
||||||
|
format!("{} streams", transport.active_streams),
|
||||||
|
));
|
||||||
|
if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 {
|
||||||
|
lines.push(status_line(
|
||||||
|
"Loss",
|
||||||
|
format!(
|
||||||
|
"{} pkts · {}",
|
||||||
|
transport.runtime_lost_packets,
|
||||||
|
short_bytes_label(transport.runtime_lost_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.push(status_line(
|
||||||
|
"Samples",
|
||||||
|
format!(
|
||||||
|
"{} total · {} direct · {} relay · {} unknown",
|
||||||
|
transport.total_samples,
|
||||||
|
transport.direct_samples,
|
||||||
|
transport.relay_samples,
|
||||||
|
transport.unknown_samples
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Protocols",
|
||||||
|
format!(
|
||||||
|
"audio {} · catalog {} · sync {}",
|
||||||
|
transport.audio_samples, transport.catalog_samples, transport.sync_samples
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if let Some(sample) = transport.last.first() {
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last stream",
|
||||||
|
format!(
|
||||||
|
"{} {} {} · {} · {}",
|
||||||
|
sample.protocol,
|
||||||
|
sample.direction,
|
||||||
|
sample.phase,
|
||||||
|
sample.selected_path,
|
||||||
|
rtt_label(sample.selected_rtt_ms)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last peer",
|
||||||
|
format!(
|
||||||
|
"{} · paths {}/{}/{}/{}",
|
||||||
|
short_id(&sample.peer_id),
|
||||||
|
sample.direct_paths,
|
||||||
|
sample.relay_paths,
|
||||||
|
sample.custom_paths,
|
||||||
|
sample.open_paths
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last bytes",
|
||||||
|
format!(
|
||||||
|
"sel {}/{} · total {}/{} · lost {}",
|
||||||
|
short_bytes_label(sample.selected_tx_bytes),
|
||||||
|
short_bytes_label(sample.selected_rx_bytes),
|
||||||
|
short_bytes_label(sample.total_tx_bytes),
|
||||||
|
short_bytes_label(sample.total_rx_bytes),
|
||||||
|
short_bytes_label(sample.lost_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn status_detail_transport_logs(state: &AppState) -> Vec<Line<'static>> {
|
||||||
|
let Some(status) = &state.federation.status else {
|
||||||
|
return vec![Line::styled("transport status is loading", theme::dim())];
|
||||||
|
};
|
||||||
|
if status.transport.last.is_empty() {
|
||||||
|
return vec![Line::styled("no connection samples yet", theme::dim())];
|
||||||
|
}
|
||||||
|
status
|
||||||
|
.transport
|
||||||
|
.last
|
||||||
|
.iter()
|
||||||
|
.map(|sample| {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{:<12}", sample.at), theme::dim()),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} {} {} · {} · {} · tx {} rx {}",
|
||||||
|
sample.protocol,
|
||||||
|
sample.direction,
|
||||||
|
sample.phase,
|
||||||
|
sample.selected_path,
|
||||||
|
rtt_label(sample.selected_rtt_ms),
|
||||||
|
short_bytes_label(sample.total_tx_bytes),
|
||||||
|
short_bytes_label(sample.total_rx_bytes)
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_detail_device_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = vec![Line::styled("Connected Devices", theme::header())];
|
||||||
match &state.federation.devices {
|
match &state.federation.devices {
|
||||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||||
Some(status) => {
|
Some(status) => {
|
||||||
@@ -882,30 +953,27 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Sync log",
|
"Sync log",
|
||||||
format!(
|
format!(
|
||||||
"{} ops · {} outbox · {} tombstones ({} gc)",
|
"{} ops · {} outbox · {} tombstones",
|
||||||
status.ops_total,
|
status.ops_total, status.outbox_ops, status.tombstone_ops
|
||||||
status.outbox_ops,
|
|
||||||
status.tombstone_ops,
|
|
||||||
status.compactable_tombstones
|
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
format!(
|
format!(
|
||||||
"{} likes, {} playlists, {} items",
|
"{} likes · {} playlists · {} items",
|
||||||
status.snapshot_likes, status.snapshot_playlists, status.snapshot_items
|
status.snapshot_likes, status.snapshot_playlists, status.snapshot_items
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Unresolved items",
|
"Unresolved",
|
||||||
status.unresolved_playlist_items.to_string(),
|
status.unresolved_playlist_items.to_string(),
|
||||||
));
|
));
|
||||||
lines.push(status_line("Peer ack floor", status.peer_ack_floor.clone()));
|
lines.push(status_line("Ack floor", status.peer_ack_floor.clone()));
|
||||||
if let Some(last_sync) = &status.last_sync {
|
if let Some(last_sync) = &status.last_sync {
|
||||||
lines.push(status_line("Last device sync", last_sync.clone()));
|
lines.push(status_line("Last sync", last_sync.clone()));
|
||||||
}
|
}
|
||||||
if let Some(last_error) = &status.last_error {
|
if let Some(last_error) = &status.last_error {
|
||||||
lines.push(status_line("Device error", last_error.clone()));
|
lines.push(status_line("Error", first_line(last_error)));
|
||||||
}
|
}
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
lines.push(Line::styled("Device List", theme::header()));
|
lines.push(Line::styled("Device List", theme::header()));
|
||||||
@@ -914,14 +982,29 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
} else {
|
} else {
|
||||||
let ordered = crate::app::state::device_status_order(state);
|
let ordered = crate::app::state::device_status_order(state);
|
||||||
let now = crate::app::state::unix_time_ms();
|
let now = crate::app::state::unix_time_ms();
|
||||||
|
let mut emitted = Vec::new();
|
||||||
for index in &ordered {
|
for index in &ordered {
|
||||||
if let Some(device) = status.devices.get(*index) {
|
if let Some(device) = status.devices.get(*index) {
|
||||||
push_device_detail(&mut lines, state, device, now);
|
push_device_detail_compact(
|
||||||
|
&mut lines,
|
||||||
|
state,
|
||||||
|
device,
|
||||||
|
now,
|
||||||
|
!emitted.is_empty(),
|
||||||
|
);
|
||||||
|
emitted.push(*index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (index, device) in status.devices.iter().enumerate() {
|
for (index, device) in status.devices.iter().enumerate() {
|
||||||
if !ordered.contains(&index) {
|
if !emitted.contains(&index) {
|
||||||
push_device_detail(&mut lines, state, device, now);
|
push_device_detail_compact(
|
||||||
|
&mut lines,
|
||||||
|
state,
|
||||||
|
device,
|
||||||
|
now,
|
||||||
|
!emitted.is_empty(),
|
||||||
|
);
|
||||||
|
emitted.push(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -930,47 +1013,80 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_device_detail(
|
fn push_device_detail_compact(
|
||||||
lines: &mut Vec<Line<'static>>,
|
lines: &mut Vec<Line<'static>>,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
device: &crate::devices::DeviceStatusRow,
|
device: &crate::devices::DeviceStatusRow,
|
||||||
now_ms: i64,
|
now_ms: i64,
|
||||||
|
separator: bool,
|
||||||
) {
|
) {
|
||||||
let mut name = crate::app::state::device_display_name(device);
|
if separator {
|
||||||
if device.is_self {
|
lines.push(Line::styled(
|
||||||
name.push_str(" (this device)");
|
"────────────────────────────────",
|
||||||
|
theme::dim(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if device.revoked {
|
let presence = crate::app::state::device_presence_section(state, device, now_ms);
|
||||||
name.push_str(" (revoked)");
|
let icon = match presence {
|
||||||
}
|
DevicePresenceSection::Online => "●",
|
||||||
let presence = match crate::app::state::device_presence_section(state, device, now_ms) {
|
DevicePresenceSection::Offline => "○",
|
||||||
DevicePresenceSection::Online => "online",
|
DevicePresenceSection::Revoked => "×",
|
||||||
DevicePresenceSection::Offline => "offline",
|
|
||||||
DevicePresenceSection::Revoked => "revoked",
|
|
||||||
};
|
};
|
||||||
lines.push(status_line("Device", name));
|
let mut badges = Vec::new();
|
||||||
|
if device.is_self {
|
||||||
|
badges.push("this");
|
||||||
|
}
|
||||||
|
match presence {
|
||||||
|
DevicePresenceSection::Online => badges.push("online"),
|
||||||
|
DevicePresenceSection::Offline => badges.push("offline"),
|
||||||
|
DevicePresenceSection::Revoked => badges.push("revoked"),
|
||||||
|
}
|
||||||
|
let version = if device.client_version.trim().is_empty() {
|
||||||
|
"v?".to_string()
|
||||||
|
} else {
|
||||||
|
format!("v{}", device.client_version)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{icon} "), theme::accent()),
|
||||||
|
Span::raw(crate::app::state::device_display_name(device)),
|
||||||
|
Span::styled(
|
||||||
|
format!(" · {} · {}", version, badges.join(", ")),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]));
|
||||||
lines.push(status_line("Device ID", device.device_id.clone()));
|
lines.push(status_line("Device ID", device.device_id.clone()));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Endpoint ID",
|
"Endpoint",
|
||||||
if device.endpoint_id.trim().is_empty() {
|
if device.endpoint_id.trim().is_empty() {
|
||||||
"unavailable".to_string()
|
"unavailable".to_string()
|
||||||
} else {
|
} else {
|
||||||
device.endpoint_id.clone()
|
short_id(&device.endpoint_id)
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Version",
|
"Last seen",
|
||||||
if device.client_version.trim().is_empty() {
|
relative_time_label(device.last_seen_ms, now_ms),
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
device.client_version.clone()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Presence",
|
|
||||||
match device.last_seen_ms {
|
|
||||||
Some(seen) => format!("{presence} · last seen {seen} ms"),
|
|
||||||
None => format!("{presence} · last seen unavailable"),
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn relative_time_label(value_ms: Option<i64>, now_ms: i64) -> String {
|
||||||
|
let Some(value_ms) = value_ms else {
|
||||||
|
return "unavailable".to_string();
|
||||||
|
};
|
||||||
|
let delta_ms = now_ms.saturating_sub(value_ms);
|
||||||
|
if delta_ms < 0 {
|
||||||
|
return "in the future".to_string();
|
||||||
|
}
|
||||||
|
let seconds = delta_ms / 1000;
|
||||||
|
if seconds < 5 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if seconds < 60 {
|
||||||
|
format!("{seconds}s ago")
|
||||||
|
} else if seconds < 60 * 60 {
|
||||||
|
format!("{}m ago", seconds / 60)
|
||||||
|
} else if seconds < 24 * 60 * 60 {
|
||||||
|
format!("{}h ago", seconds / 60 / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", seconds / 60 / 60 / 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+269
-17
@@ -6,7 +6,8 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
AppState, DevicePresenceSection, EditField, Loadable, Popup, addable_playlists,
|
AppState, DevicePresenceSection, EditField, Loadable, Popup, StatusDetailFocus,
|
||||||
|
addable_playlists,
|
||||||
};
|
};
|
||||||
use crate::library::models::{ArtistRef, TrackItem};
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
@@ -37,10 +38,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
..
|
..
|
||||||
}) => draw_track_artists(frame, tracks, *cursor, *selected),
|
}) => draw_track_artists(frame, tracks, *cursor, *selected),
|
||||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||||
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
|
Some(Popup::FedInput { field, input }) => {
|
||||||
|
draw_fed_input(frame, field.title(), field.help(), input)
|
||||||
|
}
|
||||||
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
||||||
Some(Popup::FederationStatusDetails { scroll }) => {
|
Some(Popup::FedCopyText { title, text, help }) => {
|
||||||
draw_federation_status_details(frame, state, *scroll)
|
draw_fed_copy_text(frame, title, text, help)
|
||||||
|
}
|
||||||
|
Some(Popup::FederationStatusDetails {
|
||||||
|
focus,
|
||||||
|
status_cursor,
|
||||||
|
devices_scroll,
|
||||||
|
logs_scroll,
|
||||||
|
}) => draw_federation_status_details(
|
||||||
|
frame,
|
||||||
|
state,
|
||||||
|
*focus,
|
||||||
|
*status_cursor,
|
||||||
|
*devices_scroll,
|
||||||
|
*logs_scroll,
|
||||||
|
),
|
||||||
|
Some(Popup::FederationStatusText {
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
scroll,
|
||||||
|
parent: _,
|
||||||
|
}) => draw_federation_status_text(frame, title, text, *scroll),
|
||||||
|
Some(Popup::FederationStatusLog { scroll, parent: _ }) => {
|
||||||
|
draw_federation_status_log(frame, state, *scroll)
|
||||||
}
|
}
|
||||||
Some(Popup::DevicePairing {
|
Some(Popup::DevicePairing {
|
||||||
device_id,
|
device_id,
|
||||||
@@ -65,10 +90,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_federation_status_details(frame: &mut Frame, state: &AppState, scroll: usize) {
|
fn draw_federation_status_details(
|
||||||
let width = frame.area().width.saturating_sub(6).clamp(52, 104);
|
frame: &mut Frame,
|
||||||
let height = frame.area().height.saturating_sub(4).clamp(10, 32);
|
state: &AppState,
|
||||||
let area = centered(frame.area(), width, height);
|
focus: StatusDetailFocus,
|
||||||
|
status_cursor: usize,
|
||||||
|
devices_scroll: usize,
|
||||||
|
_logs_scroll: usize,
|
||||||
|
) {
|
||||||
|
let area = federation_status_area(frame);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Full status details ")
|
.title(" Full status details ")
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
@@ -77,18 +107,182 @@ fn draw_federation_status_details(frame: &mut Frame, state: &AppState, scroll: u
|
|||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
let log_height = if inner.height >= 22 {
|
||||||
let lines = super::federation::status_detail_lines(state);
|
(inner.height / 3)
|
||||||
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
|
.clamp(7, 12)
|
||||||
|
.min(inner.height.saturating_sub(9))
|
||||||
|
} else {
|
||||||
|
inner.height.saturating_sub(7).clamp(3, 6)
|
||||||
|
};
|
||||||
|
let [summary_area, logs_area, footer] = Layout::vertical([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(log_height),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
let sections = super::federation::status_detail_sections(state, status_cursor);
|
||||||
|
if summary_area.width >= 78 {
|
||||||
|
let [left, _, right] = Layout::horizontal([
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
])
|
||||||
|
.areas(summary_area);
|
||||||
|
draw_status_detail_panel(
|
||||||
|
frame,
|
||||||
|
left,
|
||||||
|
" Status / Transport ",
|
||||||
|
sections.status,
|
||||||
|
0,
|
||||||
|
focus == StatusDetailFocus::Status,
|
||||||
|
);
|
||||||
|
draw_status_detail_panel(
|
||||||
|
frame,
|
||||||
|
right,
|
||||||
|
" Connected Devices ",
|
||||||
|
sections.devices,
|
||||||
|
devices_scroll,
|
||||||
|
focus == StatusDetailFocus::Devices,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let [top, bottom] =
|
||||||
|
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.areas(summary_area);
|
||||||
|
draw_status_detail_panel(
|
||||||
|
frame,
|
||||||
|
top,
|
||||||
|
" Status / Transport ",
|
||||||
|
sections.status,
|
||||||
|
0,
|
||||||
|
focus == StatusDetailFocus::Status,
|
||||||
|
);
|
||||||
|
draw_status_detail_panel(
|
||||||
|
frame,
|
||||||
|
bottom,
|
||||||
|
" Connected Devices ",
|
||||||
|
sections.devices,
|
||||||
|
devices_scroll,
|
||||||
|
focus == StatusDetailFocus::Devices,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
draw_status_log_panel(
|
||||||
|
frame,
|
||||||
|
logs_area,
|
||||||
|
sections.logs,
|
||||||
|
focus == StatusDetailFocus::Logs,
|
||||||
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(lines)
|
Paragraph::new(Line::styled(
|
||||||
|
"h/l focus panels · j/k move/scroll selected · enter open selected detail/log · esc close",
|
||||||
|
theme::dim(),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
footer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn federation_status_area(frame: &Frame) -> Rect {
|
||||||
|
let max_width = frame.area().width.saturating_sub(2).max(1);
|
||||||
|
let max_height = frame.area().height.saturating_sub(2).max(1);
|
||||||
|
let width = max_width.min(150).max(max_width.min(72));
|
||||||
|
let height = max_height.min(44).max(max_height.min(22));
|
||||||
|
centered(frame.area(), width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_detail_panel(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
title: &'static str,
|
||||||
|
lines: Vec<Line<'static>>,
|
||||||
|
scroll: usize,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
if area.width == 0 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(title)
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(if focused {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
});
|
||||||
|
let inner = block.inner(area);
|
||||||
|
let max_scroll = lines.len().saturating_sub(usize::from(inner.height));
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).scroll((scroll.min(max_scroll) as u16, 0)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_log_panel(frame: &mut Frame, area: Rect, lines: Vec<Line<'static>>, focused: bool) {
|
||||||
|
if area.width == 0 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Connection log ")
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(if focused {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
});
|
||||||
|
let inner = block.inner(area);
|
||||||
|
let preview: Vec<_> = lines.into_iter().take(10).collect();
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
frame.render_widget(Paragraph::new(preview), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_federation_status_text(frame: &mut Frame, title: &str, text: &str, scroll: usize) {
|
||||||
|
let area = federation_status_area(frame);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" {title} "))
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||||
|
let line_count = text.lines().count().max(1);
|
||||||
|
let max_scroll = line_count.saturating_sub(usize::from(body.height));
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(text.to_string())
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((scroll.min(max_scroll) as u16, 0)),
|
.scroll((scroll.min(max_scroll) as u16, 0)),
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(
|
Paragraph::new(Line::styled(
|
||||||
"j/k scroll - pgup/pgdn page - esc close",
|
"j/k scroll · pgup/pgdn page · esc return",
|
||||||
|
theme::dim(),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
footer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_federation_status_log(frame: &mut Frame, state: &AppState, scroll: usize) {
|
||||||
|
let area = federation_status_area(frame);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Connection log ")
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||||
|
let lines = super::federation::status_detail_transport_logs(state);
|
||||||
|
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).scroll((scroll.min(max_scroll) as u16, 0)),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(
|
||||||
|
"j/k scroll · pgup/pgdn page · esc return",
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
))
|
))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
@@ -428,8 +622,8 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
fn draw_fed_input(frame: &mut Frame, title: &str, help: &str, input: &crate::app::input::LineEdit) {
|
||||||
let area = centered(frame.area(), 64, 5);
|
let area = centered(frame.area(), 72, 8);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(format!(" {title} "))
|
.title(format!(" {title} "))
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
@@ -437,8 +631,18 @@ fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::Lin
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
let [entry_area, hint_area] =
|
let [help_area, entry_area, hint_area] = Layout::vertical([
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(help.to_string())
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.style(theme::dim()),
|
||||||
|
help_area,
|
||||||
|
);
|
||||||
let spans = super::line_edit_spans(input, usize::from(entry_area.width.saturating_sub(1)));
|
let spans = super::line_edit_spans(input, usize::from(entry_area.width.saturating_sub(1)));
|
||||||
frame.render_widget(Paragraph::new(Line::from(spans)), entry_area);
|
frame.render_widget(Paragraph::new(Line::from(spans)), entry_area);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -471,6 +675,54 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrapped text with an explicit copy-and-close action.
|
||||||
|
fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) {
|
||||||
|
let width = frame.area().width.saturating_sub(8).clamp(36, 96);
|
||||||
|
let text_width = usize::from(width.saturating_sub(2));
|
||||||
|
let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16;
|
||||||
|
let help_lines = (help.chars().count() / text_width.max(1) + 1) as u16;
|
||||||
|
let height = (text_lines + help_lines + 5).clamp(8, frame.area().height);
|
||||||
|
let area = centered(frame.area(), width, height);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" {title} "))
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [help_area, body_area, button_area, footer_area] = Layout::vertical([
|
||||||
|
Constraint::Length(help_lines),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(help.to_string())
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.style(theme::dim()),
|
||||||
|
help_area,
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
|
||||||
|
body_area,
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(
|
||||||
|
" Copy to clipboard and close ",
|
||||||
|
theme::tab_active(),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
button_area,
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim()))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
footer_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_device_pairing(
|
fn draw_device_pairing(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user