Reworked statistics

This commit is contained in:
Ultradesu
2026-07-26 00:45:53 +03:00
parent 76f67372a1
commit 17eb6a4fee
8 changed files with 867 additions and 259 deletions
+294 -178
View File
@@ -73,7 +73,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let (label, value) = match row {
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
FedRow::NetworkId => (
"Network ID (shared secret)",
"Network ID",
if settings.network_id.is_empty() {
"(not set — press enter)".to_string()
} else {
@@ -84,7 +84,14 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
"Save federated tracks to the library on listen",
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::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,
"Sync devices now",
if connected_devices_enabled {
"".to_string()
if state.federation.device_syncing {
format!("{} syncing", state.spinner())
} else {
"".to_string()
}
} else {
disabled_value.clone()
},
@@ -428,107 +439,6 @@ fn short_id(id: &str) -> 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) {
if area.width == 0 || area.height == 0 {
return;
@@ -790,65 +700,91 @@ fn first_line(value: &str) -> 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())];
match &state.federation.status {
None => lines.push(Line::styled("loading…", theme::dim())),
Some(status) if !status.running => {
lines.push(status_line("Node", "stopped".to_string()));
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::styled(
"Enable federation and set a network id — every instance using the",
theme::dim(),
));
lines.push(Line::styled(
"same id (furumi TUI or furumi-fd) finds the others automatically.",
"Set the same Network ID on each client.",
theme::dim(),
));
}
Some(status) => {
lines.push(status_line("Node", format!("running · {}", status.network)));
lines.push(status_line(
lines.push(status_action_line(
"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!(
"{} 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(),
more,
status.known_contacts,
names.join(", ")
)
};
lines.push(status_line("Peers", peers));
status.known_contacts
),
));
if !status.connected_peers.is_empty() {
let mut peers: Vec<String> = status
.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(
"DHT",
format!(
"{} records · {} · {} published",
"{} records · {}",
status
.stored_dht_records
.map(|count| count.to_string())
.unwrap_or_else(|| "unavailable".to_string()),
.unwrap_or_else(|| "n/a".to_string()),
status
.stored_dht_bytes
.map(short_bytes_label)
.unwrap_or_else(|| "unavailable".to_string()),
status.published_items
.unwrap_or_else(|| "n/a".to_string())
),
));
lines.push(status_line(
"Published",
format!("{} items", status.published_items),
));
lines.push(status_line(
"Last sync",
status
@@ -857,13 +793,148 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
.unwrap_or_else(|| "not yet".to_string()),
));
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::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 {
None => lines.push(Line::styled("loading…", theme::dim())),
Some(status) => {
@@ -882,30 +953,27 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
lines.push(status_line(
"Sync log",
format!(
"{} ops · {} outbox · {} tombstones ({} gc)",
status.ops_total,
status.outbox_ops,
status.tombstone_ops,
status.compactable_tombstones
"{} ops · {} outbox · {} tombstones",
status.ops_total, status.outbox_ops, status.tombstone_ops
),
));
lines.push(status_line(
"Snapshot",
format!(
"{} likes, {} playlists, {} items",
"{} likes · {} playlists · {} items",
status.snapshot_likes, status.snapshot_playlists, status.snapshot_items
),
));
lines.push(status_line(
"Unresolved items",
"Unresolved",
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 {
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 {
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::styled("Device List", theme::header()));
@@ -914,14 +982,29 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec<Line<'static>> {
} else {
let ordered = crate::app::state::device_status_order(state);
let now = crate::app::state::unix_time_ms();
let mut emitted = Vec::new();
for index in &ordered {
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() {
if !ordered.contains(&index) {
push_device_detail(&mut lines, state, device, now);
if !emitted.contains(&index) {
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
}
fn push_device_detail(
fn push_device_detail_compact(
lines: &mut Vec<Line<'static>>,
state: &AppState,
device: &crate::devices::DeviceStatusRow,
now_ms: i64,
separator: bool,
) {
let mut name = crate::app::state::device_display_name(device);
if device.is_self {
name.push_str(" (this device)");
if separator {
lines.push(Line::styled(
"────────────────────────────────",
theme::dim(),
));
}
if device.revoked {
name.push_str(" (revoked)");
}
let presence = match crate::app::state::device_presence_section(state, device, now_ms) {
DevicePresenceSection::Online => "online",
DevicePresenceSection::Offline => "offline",
DevicePresenceSection::Revoked => "revoked",
let presence = crate::app::state::device_presence_section(state, device, now_ms);
let icon = match presence {
DevicePresenceSection::Online => "",
DevicePresenceSection::Offline => "",
DevicePresenceSection::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(
"Endpoint ID",
"Endpoint",
if device.endpoint_id.trim().is_empty() {
"unavailable".to_string()
} else {
device.endpoint_id.clone()
short_id(&device.endpoint_id)
},
));
lines.push(status_line(
"Version",
if device.client_version.trim().is_empty() {
"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"),
},
"Last seen",
relative_time_label(device.last_seen_ms, now_ms),
));
}
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
View File
@@ -6,7 +6,8 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme;
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};
@@ -37,10 +38,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
..
}) => draw_track_artists(frame, tracks, *cursor, *selected),
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::FederationStatusDetails { scroll }) => {
draw_federation_status_details(frame, state, *scroll)
Some(Popup::FedCopyText { title, text, help }) => {
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 {
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) {
let width = frame.area().width.saturating_sub(6).clamp(52, 104);
let height = frame.area().height.saturating_sub(4).clamp(10, 32);
let area = centered(frame.area(), width, height);
fn draw_federation_status_details(
frame: &mut Frame,
state: &AppState,
focus: StatusDetailFocus,
status_cursor: usize,
devices_scroll: usize,
_logs_scroll: usize,
) {
let area = federation_status_area(frame);
let block = Block::bordered()
.title(" Full status details ")
.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(block, area);
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
let lines = super::federation::status_detail_lines(state);
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
let log_height = if inner.height >= 22 {
(inner.height / 3)
.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(
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 })
.scroll((scroll.min(max_scroll) as u16, 0)),
body,
);
frame.render_widget(
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(),
))
.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).
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
let area = centered(frame.area(), 64, 5);
fn draw_fed_input(frame: &mut Frame, title: &str, help: &str, input: &crate::app::input::LineEdit) {
let area = centered(frame.area(), 72, 8);
let block = Block::bordered()
.title(format!(" {title} "))
.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);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [entry_area, hint_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
let [help_area, entry_area, hint_area] = Layout::vertical([
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)));
frame.render_widget(Paragraph::new(Line::from(spans)), entry_area);
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(
frame: &mut Frame,
device_id: &str,