Files
furumi_tui/src/ui/popup.rs
T
2026-07-28 18:35:59 +01:00

1584 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme;
use crate::app::state::{
AppState, DevicePresenceSection, EditField, Loadable, Popup, StatusDetailFocus,
addable_playlists,
};
use crate::library::models::{ArtistRef, TrackItem};
pub fn draw(frame: &mut Frame, state: &AppState) {
match state.popup.as_ref() {
Some(Popup::AddToPlaylist { target, cursor }) => {
draw_picker(frame, state, &target.label(), *cursor)
}
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, state, input, *busy),
Some(Popup::Edit {
title,
fields,
focus,
error,
..
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
Some(Popup::TrackInfo {
tracks,
cursor,
scroll,
}) => draw_track_info(frame, state, tracks, *cursor, *scroll),
Some(Popup::TrackArtists {
tracks,
cursor,
selected,
..
}) => draw_track_artists(frame, state, tracks, *cursor, *selected),
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, state, entry),
Some(Popup::FedInput { field, input }) => {
draw_fed_input(frame, state, field.title(), field.help(), input)
}
Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text),
Some(Popup::FedCopyText { title, text, help }) => {
draw_fed_copy_text(frame, state, 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, state, title, text, *scroll),
Some(Popup::FederationStatusLog { scroll, parent: _ }) => {
draw_federation_status_log(frame, state, *scroll)
}
Some(Popup::DevicePairing {
device_id,
name,
client_version,
requester_group_id,
requester_group_active_devices,
..
}) => draw_device_pairing(
frame,
state,
device_id,
name,
client_version,
requester_group_id.as_deref(),
*requester_group_active_devices,
),
Some(Popup::ConfirmDeviceRevoke { device_id, name }) => {
draw_device_revoke(frame, state, device_id, name)
}
Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state),
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
Some(Popup::ListenHistory { cursor }) => draw_listen_history(frame, state, *cursor),
None => {}
}
}
fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(
frame.area(),
100,
frame.area().height.saturating_sub(4).clamp(10, 30),
);
let block = Block::bordered()
.title(" Listening history ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
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);
match state.listen_history.as_ref() {
Some(Loadable::Loading) | None => {
frame.render_widget(
Paragraph::new(format!("{} Loading history…", state.spinner()))
.alignment(Alignment::Center),
body,
);
}
Some(Loadable::Failed(err)) => {
frame.render_widget(
Paragraph::new(format!("History unavailable: {err}"))
.style(theme::dim())
.wrap(Wrap { trim: true }),
body,
);
}
Some(Loadable::Ready(entries)) if entries.is_empty() => {
frame.render_widget(
Paragraph::new("No qualified listens yet.")
.style(theme::dim())
.alignment(Alignment::Center),
body,
);
}
Some(Loadable::Ready(entries)) => {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or_default();
let rows = entries.iter().map(|entry| {
Row::new(vec![
Cell::from(entry.title.clone()),
Cell::from(entry.artist.clone()),
Cell::from(relative_listen_time(entry.started_at_ms, now_ms)),
Cell::from(history_device_name(state, &entry.origin_device_id)),
])
});
let mut table_state =
TableState::default().with_selected(cursor.min(entries.len() - 1));
let table = Table::new(
rows,
[
Constraint::Percentage(34),
Constraint::Percentage(28),
Constraint::Length(12),
Constraint::Percentage(26),
],
)
.header(Row::new(["Track", "Artist", "When", "Device"]).style(theme::header_for(state)))
.row_highlight_style(theme::selection_for(state))
.highlight_symbol(" ");
frame.render_stateful_widget(table, body, &mut table_state);
}
}
frame.render_widget(
Paragraph::new("j/k scroll · pgup/pgdn page · esc close")
.style(theme::dim())
.alignment(Alignment::Center),
footer,
);
}
fn history_device_name(state: &AppState, device_id: &str) -> String {
state
.federation
.devices
.as_ref()
.and_then(|status| {
status
.devices
.iter()
.find(|device| device.device_id == device_id)
.map(|device| device.name.clone())
})
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| {
if device_id == state.device_playback.self_device_id {
state.device_playback.self_device_name.clone()
} else {
device_id.chars().take(10).collect()
}
})
}
fn relative_listen_time(started_at_ms: i64, now_ms: i64) -> String {
let elapsed = now_ms.saturating_sub(started_at_ms).max(0) / 1_000;
match elapsed {
0..=59 => "now".to_string(),
60..=3_599 => format!("{}m ago", elapsed / 60),
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
86_400..=604_799 => format!("{}d ago", elapsed / 86_400),
_ => format!("{}w ago", elapsed / 604_800),
}
}
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_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
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,
state,
" Status / Transport ",
sections.status,
0,
focus == StatusDetailFocus::Status,
);
draw_status_detail_panel(
frame,
right,
state,
" 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,
state,
" Status / Transport ",
sections.status,
0,
focus == StatusDetailFocus::Status,
);
draw_status_detail_panel(
frame,
bottom,
state,
" Connected Devices ",
sections.devices,
devices_scroll,
focus == StatusDetailFocus::Devices,
);
}
draw_status_log_panel(
frame,
logs_area,
state,
sections.logs,
focus == StatusDetailFocus::Logs,
);
frame.render_widget(
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,
state: &AppState,
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_for(state))
.border_style(if focused {
theme::strong_border_for(state)
} 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,
state: &AppState,
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_for(state))
.border_style(if focused {
theme::strong_border_for(state)
} 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,
state: &AppState,
title: &str,
text: &str,
scroll: usize,
) {
let area = federation_status_area(frame);
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
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 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_for(state))
.border_style(theme::strong_border_for(state));
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),
footer,
);
}
fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
let rows = crate::app::popup::connected_device_rows(state);
let self_row = rows.iter().find(|row| row.is_self);
let other_rows: Vec<_> = rows.iter().filter(|row| !row.is_self).collect();
let cursor = cursor.min(other_rows.len());
enum DisplayLine {
Section(DevicePresenceSection),
Row(usize),
}
let mut display_lines = Vec::new();
let mut last_section = None;
for (index, row) in other_rows.iter().enumerate() {
if last_section != Some(row.section) {
display_lines.push(DisplayLine::Section(row.section));
last_section = Some(row.section);
}
display_lines.push(DisplayLine::Row(index));
}
let height =
(display_lines.len() as u16 + 14).clamp(16, frame.area().height.saturating_sub(2).max(16));
let area = centered(frame.area(), 76, height);
let block = Block::bordered()
.title(" Connected devices ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [summary_area, this_area, jam_area, other_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(4),
Constraint::Min(1),
Constraint::Length(2),
])
.areas(inner);
let active = state.device_playback.active_label();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("Role ", theme::dim()),
Span::styled(
format!(" {} ", state.device_playback.role.label()),
theme::role_pill(state.device_playback.role),
),
Span::raw(" "),
Span::styled("Active ", theme::dim()),
Span::raw(active),
])),
summary_area,
);
render_subtitle(frame, this_area, state, "This device");
let action_area = Rect {
x: this_area.x,
y: this_area.y + 1,
width: this_area.width,
height: 1,
};
let action_label = if state.device_playback.is_audio_owner() {
"This device is active"
} else {
"Make this device active"
};
let self_name = self_row
.map(|row| row.name.as_str())
.unwrap_or(state.device_playback.self_device_name.as_str());
let self_status = self_row
.map(device_status_label)
.unwrap_or_else(|| "● · ■ Q0".to_string());
render_connected_action(
frame,
action_area,
cursor == 0,
action_label,
self_name,
&self_status,
state,
);
render_subtitle(frame, jam_area, state, "Jam");
let jam_status = match state.jam.role {
crate::jam::JamRole::None => "inactive · h host · J join".to_string(),
crate::jam::JamRole::Host => format!(
"HOST {} · {} participant(s) · c copy invite · h regenerate · l leave",
state
.jam
.jam_id
.as_deref()
.unwrap_or_default()
.chars()
.take(12)
.collect::<String>(),
state.jam.participants.len()
),
crate::jam::JamRole::Participant => format!(
"{} · {} · {} participant(s) · l leave",
if state.jam.connected {
"CONNECTED"
} else {
"RECONNECTING"
},
state.jam.host_name.as_deref().unwrap_or("Jam host"),
state.jam.participants.len()
),
};
frame.render_widget(
Paragraph::new(Line::styled(
format!(" {jam_status}"),
if state.jam.role == crate::jam::JamRole::None {
theme::dim()
} else {
theme::accent_for(state)
},
)),
Rect {
x: jam_area.x,
y: jam_area.y + 1,
width: jam_area.width,
height: 1,
},
);
if let Some(error) = state.jam.last_error.as_deref() {
frame.render_widget(
Paragraph::new(Line::styled(format!(" {error}"), theme::dim())),
Rect {
x: jam_area.x,
y: jam_area.y + 2,
width: jam_area.width,
height: 1,
},
);
}
render_subtitle(frame, other_area, state, "Other devices");
let list_area = Rect {
x: other_area.x,
y: other_area.y + 1,
width: other_area.width,
height: other_area.height.saturating_sub(1),
};
if other_rows.is_empty() {
frame.render_widget(
Paragraph::new(Line::styled(" no other devices yet", theme::dim())),
list_area,
);
}
let visible = usize::from(list_area.height.max(1));
let selected_remote = cursor.checked_sub(1);
let selected_line = selected_remote
.and_then(|selected| {
display_lines
.iter()
.position(|line| matches!(line, DisplayLine::Row(index) if *index == selected))
})
.unwrap_or(0);
let first = selected_line
.saturating_sub(visible / 2)
.min(display_lines.len().saturating_sub(visible));
for (line_index, line) in display_lines.iter().enumerate().skip(first).take(visible) {
let area = Rect {
x: list_area.x,
y: list_area.y + (line_index - first) as u16,
width: list_area.width,
height: 1,
};
let DisplayLine::Row(index) = line else {
let DisplayLine::Section(section) = line else {
continue;
};
frame.render_widget(
Paragraph::new(Line::styled(section.title(), theme::header_for(state))),
area,
);
continue;
};
let row = other_rows[*index];
render_connected_device_row(frame, area, row, state, selected_remote == Some(*index));
}
let [legend_area, controls_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(hint_area);
frame.render_widget(
Paragraph::new(Line::styled(
"● on ○ off ◆ active ◇ control ▶ play II pause ■ stop Q queue",
theme::dim(),
))
.alignment(Alignment::Center),
legend_area,
);
frame.render_widget(
Paragraph::new(Line::styled(
"enter device · h host Jam · J join · c copy · l leave · esc close",
theme::dim(),
))
.alignment(Alignment::Center),
controls_area,
);
}
fn render_subtitle(frame: &mut Frame, area: Rect, state: &AppState, title: &'static str) {
let rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::styled(title, theme::header_for(state))),
rect,
);
}
fn device_status_label(row: &crate::app::popup::ConnectedDevicePopupRow) -> String {
let online = if row.online { "●" } else { "○" };
let role = if row.active {
"◆"
} else if row.is_self {
"◇"
} else {
"·"
};
let play = if row.playing && row.paused {
"II"
} else if row.playing {
"▶"
} else {
"■"
};
format!("{online} {role} {play} Q{}", row.queue_len)
}
fn render_connected_action(
frame: &mut Frame,
area: Rect,
selected: bool,
label: &str,
device_name: &str,
status: &str,
state: &AppState,
) {
let marker = if selected { "▶ " } else { " " };
let prefix = format!("{marker}{label}");
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
let status_width = UnicodeWidthStr::width(status);
let name_width = usize::from(area.width).saturating_sub(prefix_width + status_width + 3);
let suffix = format!("{} {status}", clip_cells(device_name, name_width));
let suffix_width = UnicodeWidthStr::width(suffix.as_str());
let total_width = usize::from(area.width);
let label = if prefix_width + suffix_width + 1 > total_width {
let available = total_width.saturating_sub(suffix_width + UnicodeWidthStr::width(marker));
format!("{marker}{}", clip_cells(label, available))
} else {
prefix
};
let gap = " ".repeat(total_width.saturating_sub(
UnicodeWidthStr::width(label.as_str()) + UnicodeWidthStr::width(suffix.as_str()),
));
let line = Line::from(vec![
Span::styled(
label,
if selected {
theme::accent_for(state)
} else {
theme::dim()
},
),
Span::raw(gap),
Span::styled(suffix, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), area);
if selected {
frame
.buffer_mut()
.set_style(area, theme::tab_active_for(state));
}
}
fn render_connected_device_row(
frame: &mut Frame,
area: Rect,
row: &crate::app::popup::ConnectedDevicePopupRow,
state: &AppState,
selected: bool,
) {
let marker = if selected { "▶ " } else { " " };
let status = device_status_label(row);
let marker_width = UnicodeWidthStr::width(marker);
let status_width = UnicodeWidthStr::width(status.as_str());
let total_width = usize::from(area.width);
let name_width = total_width.saturating_sub(marker_width + status_width + 1);
let name = clip_cells(&row.name, name_width);
let used_width = marker_width + UnicodeWidthStr::width(name.as_str()) + status_width;
let gap = " ".repeat(total_width.saturating_sub(used_width));
let line = Line::from(vec![
Span::styled(
marker,
if selected {
theme::accent_for(state)
} else {
theme::dim()
},
),
Span::raw(name),
Span::raw(gap),
Span::styled(status, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), area);
if selected {
frame
.buffer_mut()
.set_style(area, theme::tab_active_for(state));
}
}
fn clip_cells(text: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "…".to_string();
}
let mut out = String::new();
let mut width = 1usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > max_width {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(frame.area(), 44, 6);
let block = Block::bordered()
.title(" Library filters ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let mut rows: Vec<Line<'static>> = Vec::new();
let checked = if state.global.filters.hide_featured_only {
"[x]"
} else {
"[ ]"
};
rows.push(Line::from(vec![
Span::styled(format!("{checked} "), theme::accent_for(state)),
Span::raw("Hide featured only"),
]));
for (index, line) in rows.into_iter().enumerate() {
let row = Rect {
y: list_area.y + index as u16,
height: 1,
..list_area
};
frame.render_widget(Paragraph::new(line), row);
if cursor == index {
frame
.buffer_mut()
.set_style(row, theme::tab_active_for(state));
}
}
frame.render_widget(
Paragraph::new(Line::styled("space/enter select · esc close", theme::dim()))
.alignment(Alignment::Center),
footer,
);
}
/// One-line text entry on the Federation tab (network id / peer ticket).
fn draw_fed_input(
frame: &mut Frame,
state: &AppState,
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_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
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(
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
.alignment(Alignment::Center),
hint_area,
);
}
/// Read-only wrapped text (this peer's federation ticket).
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
let text_width = usize::from(width.saturating_sub(2));
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
let area = centered(
frame.area(),
width,
lines_needed.clamp(5, frame.area().height),
);
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
inner,
);
}
/// Wrapped text with an explicit copy-and-close action.
fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, 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_for(state))
.border_style(theme::strong_border_for(state));
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_for(state),
))
.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,
state: &AppState,
device_id: &str,
name: &str,
client_version: &str,
requester_group_id: Option<&str>,
requester_group_active_devices: usize,
) {
let group_conflict = requester_group_id.is_some() && requester_group_active_devices > 1;
let area = centered(
frame.area(),
if group_conflict { 76 } else { 64 },
if group_conflict { 12 } else { 8 },
);
let block = Block::bordered()
.title(" Pair device ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let mut lines = vec![
Line::from(vec![
Span::styled("Name ", theme::dim()),
Span::raw(name.to_string()),
]),
Line::from(vec![
Span::styled("Version ", theme::dim()),
Span::raw(client_version.to_string()),
]),
Line::from(vec![
Span::styled("Device ", theme::dim()),
Span::raw(device_id.chars().take(24).collect::<String>()),
]),
];
if let Some(group_id) = requester_group_id.filter(|_| group_conflict) {
lines.extend([
Line::from(vec![
Span::styled("Group ", theme::dim()),
Span::raw(format!(
"{} · {requester_group_active_devices} active devices",
group_id.chars().take(24).collect::<String>()
)),
]),
Line::default(),
Line::styled(
"Recommended joins that group and keeps its peers syncing.",
theme::dim(),
),
Line::styled(
"Cancel keeps this group; that device will switch groups and its peers may stop syncing.",
theme::dim(),
),
Line::default(),
Line::from(vec![
Span::styled(" Recommended ", theme::tab_active_for(state)),
Span::raw(" "),
Span::styled(" Cancel ", theme::danger_button()),
])
.alignment(Alignment::Center),
Line::styled("y recommended · c cancel · n/esc deny", theme::dim())
.alignment(Alignment::Center),
]);
} else {
lines.extend([
Line::default(),
Line::styled("y accept · n/esc deny", theme::dim()),
]);
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_device_revoke(frame: &mut Frame, state: &AppState, device_id: &str, name: &str) {
let area = centered(frame.area(), 64, 8);
let block = Block::bordered()
.title(" Revoke device ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [details, _, buttons, hint, _] = Layout::vertical([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
let lines = vec![
Line::from(vec![
Span::styled("Device ", theme::dim()),
Span::raw(name.to_string()),
]),
Line::from(vec![
Span::styled("ID ", theme::dim()),
Span::raw(device_id.chars().take(24).collect::<String>()),
]),
];
frame.render_widget(Paragraph::new(lines), details);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Yes ", theme::danger_button()),
Span::raw(" "),
Span::styled(" Cancel ", theme::tab_active_for(state)),
]))
.alignment(Alignment::Center),
buttons,
);
frame.render_widget(
Paragraph::new(Line::styled("y revoke · enter/esc cancel", theme::dim()))
.alignment(Alignment::Center),
hint,
);
}
fn draw_device_leave(frame: &mut Frame, state: &AppState) {
let area = centered(frame.area(), 72, 9);
let block = Block::bordered()
.title(" Leave device group ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [body, _, buttons, hint, _] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
frame.render_widget(
Paragraph::new(vec![
Line::raw("This device will revoke itself from the current device group."),
Line::raw("After the revoke is synced, it will start a new empty group."),
Line::styled(
"Local music, likes and playlists stay on this device.",
theme::dim(),
),
]),
body,
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Yes ", theme::danger_button()),
Span::raw(" "),
Span::styled(" Cancel ", theme::tab_active_for(state)),
]))
.alignment(Alignment::Center),
buttons,
);
frame.render_widget(
Paragraph::new(Line::styled(
"y leave group · enter/esc cancel",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
/// Metadata edit form: one bordered input per field, the focused field gets
/// the accent border and a cursor block.
fn draw_edit(
frame: &mut Frame,
state: &AppState,
title: &str,
fields: &[EditField],
focus: usize,
error: Option<&str>,
) {
let height = (fields.len() as u16 * 3 + 4).min(frame.area().height.saturating_sub(2));
let area = centered(frame.area(), 60, height);
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let mut constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
constraints.push(Constraint::Min(0));
constraints.push(Constraint::Length(1));
let areas = Layout::vertical(constraints).split(inner);
for (index, field) in fields.iter().enumerate() {
let focused = index == focus;
let field_block = Block::bordered()
.title(field.label)
.border_style(if focused {
theme::strong_border_for(state)
} else {
theme::dim()
});
let field_inner = field_block.inner(areas[index]);
frame.render_widget(field_block, areas[index]);
let width = usize::from(field_inner.width);
if focused {
let spans = super::line_edit_spans(&field.value, width);
frame.render_widget(Paragraph::new(Line::from(spans)), field_inner);
} else {
let shown: String = field
.value
.chars()
.skip(field.value.chars().count().saturating_sub(width))
.collect();
frame.render_widget(Paragraph::new(shown), field_inner);
}
}
let footer = areas[areas.len() - 1];
let hint = match error {
Some(error) => Line::styled(error.to_string(), theme::accent_for(state)),
None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()),
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
}
fn draw_confirm_delete(frame: &mut Frame, state: &AppState, label: &str) {
let width = 64.min(frame.area().width.saturating_sub(4)).max(30);
let area = centered(frame.area(), width, 7);
let block = Block::bordered()
.title(" Delete? ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
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);
frame.render_widget(
Paragraph::new(format!("Delete {label}?"))
.wrap(Wrap { trim: false })
.alignment(Alignment::Center),
body,
);
frame.render_widget(
Paragraph::new(Line::styled("enter/y delete · esc/n cancel", theme::dim()))
.alignment(Alignment::Center),
footer,
);
}
fn draw_log_detail(frame: &mut Frame, state: &AppState, entry: &crate::config::logging::LogEntry) {
let width = 90.min(frame.area().width.saturating_sub(4)).max(40);
let height = 18.min(frame.area().height.saturating_sub(2)).max(7);
let area = centered(frame.area(), width, height);
let block = Block::bordered()
.title(format!(" Log entry — {} {} ", entry.time, entry.level))
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [target_area, body, footer] = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(Line::styled(entry.target.clone(), theme::dim())),
target_area,
);
frame.render_widget(
Paragraph::new(entry.message.clone()).wrap(ratatui::widgets::Wrap { trim: false }),
body,
);
frame.render_widget(
Paragraph::new(Line::styled("esc close", theme::dim())).alignment(Alignment::Center),
footer,
);
}
fn draw_track_info(
frame: &mut Frame,
state: &AppState,
tracks: &[TrackItem],
cursor: usize,
scroll: usize,
) {
let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else {
return;
};
let width = 92.min(frame.area().width.saturating_sub(4)).max(48);
let height = 24.min(frame.area().height.saturating_sub(2)).max(10);
let area = centered(frame.area(), width, height);
let title = if tracks.len() > 1 {
format!(" Track info — {}/{} ", cursor + 1, tracks.len())
} else {
" Track info ".to_string()
};
let block = Block::bordered()
.title(title)
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
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 = track_info_lines(track);
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll.min(max_scroll) as u16, 0)),
body,
);
let can_share = crate::share::track_can_share(track);
let hint = if tracks.len() > 1 && can_share {
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
} else if tracks.len() > 1 {
"j/k scroll · h/left previous · l/right next · a artist · esc close"
} else if can_share {
"j/k scroll · a artist · c copy frid link · esc close"
} else {
"j/k scroll · a artist · esc close"
};
frame.render_widget(
Paragraph::new(Line::styled(hint, theme::dim())).alignment(Alignment::Center),
footer,
);
}
fn draw_track_artists(
frame: &mut Frame,
state: &AppState,
tracks: &[TrackItem],
cursor: usize,
selected: usize,
) {
let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else {
return;
};
let artists = crate::app::update::track_artist_refs(track);
// Main artists form the prefix of the deduplicated list; everything
// after them came from the featured credits.
let featured_from = artists
.iter()
.take_while(|kept| {
track
.artists
.iter()
.any(|main| main.id == kept.id && main.name == kept.name)
})
.count();
let height = (artists.len() as u16 + 4)
.min(frame.area().height.saturating_sub(2))
.max(6);
let area = centered(frame.area(), 44, height);
let block = Block::bordered()
.title(" Open artist ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let visible = usize::from(list_area.height.max(1));
let first = selected
.saturating_sub(visible / 2)
.min(artists.len().saturating_sub(visible));
for (index, artist) in artists.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: list_area.x,
y: list_area.y + (index - first) as u16,
width: list_area.width,
height: 1,
};
let line = if index >= featured_from {
Line::from(vec![
Span::raw(artist.name.clone()),
Span::styled(" feat.", theme::dim()),
])
} else {
Line::raw(artist.name.clone())
};
frame.render_widget(Paragraph::new(line), row);
if index == selected {
frame
.buffer_mut()
.set_style(row, theme::tab_active_for(state));
}
}
frame.render_widget(
Paragraph::new(Line::styled(
"enter open card · esc back to info",
theme::dim(),
))
.alignment(Alignment::Center),
footer,
);
}
fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
let mut lines = vec![
field("ID", row_id(track.id)),
field("Title", track.title.clone()),
field("Artists", artist_refs(&track.artists)),
field("Featured artists", artist_refs(&track.featured_artists)),
field("Release", release_label(track)),
field("Release ID", row_id(track.release_id)),
field("Disc", opt_display(track.disc_number)),
field("Track number", opt_display(track.track_number)),
field(
"Duration",
format!(
"{} ({:.2}s)",
track.duration_label(),
track.duration_seconds
),
),
field("Audio format", opt_string(track.audio_format.clone())),
field(
"Bitrate",
opt_map(track.audio_bitrate, |v| format!("{v} kbps")),
),
field(
"Sample rate",
opt_map(track.audio_sample_rate, |v| {
format!("{:.1} kHz", f64::from(v) / 1000.0)
}),
),
field(
"Bit depth",
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
),
field("File size", file_size(track.file_size_bytes)),
field("Plays", track.play_count.to_string()),
field(
"Content ID",
crate::share::track_content_id(track).unwrap_or_else(|| {
if !track.file_path.trim().is_empty() {
"press c to compute".to_string()
} else {
"—".to_string()
}
}),
),
field("File path", empty_dash(&track.file_path)),
field("Cover path", opt_string(track.cover_path.clone())),
];
if let Some(link) = crate::share::cached_track_share_link(track) {
lines.push(field("Share link", link));
}
lines
}
fn field(label: &'static str, value: String) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<18}"), theme::dim()),
Span::raw(value),
])
}
fn artist_refs(items: &[ArtistRef]) -> String {
if items.is_empty() {
return "—".to_string();
}
items
.iter()
.map(|artist| {
if artist.id >= 0 {
format!("{} ({})", artist.name, artist.id)
} else {
artist.name.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn row_id(id: i64) -> String {
if id >= 0 {
id.to_string()
} else {
"—".to_string()
}
}
fn release_label(track: &TrackItem) -> String {
let mut label = empty_dash(&track.release_title);
if let Some(year) = track.release_year {
label.push_str(&format!(" ({year})"));
}
label
}
fn empty_dash(value: &str) -> String {
if value.trim().is_empty() {
"—".to_string()
} else {
value.to_string()
}
}
fn opt_string(value: Option<String>) -> String {
value
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "—".to_string())
}
fn opt_display<T: std::fmt::Display>(value: Option<T>) -> String {
opt_map(value, |value| value.to_string())
}
fn opt_map<T>(value: Option<T>, format: impl FnOnce(T) -> String) -> String {
value.map(format).unwrap_or_else(|| "—".to_string())
}
fn file_size(value: Option<i64>) -> String {
value
.map(|bytes| format!("{:.1} MB ({} bytes)", bytes as f64 / 1_048_576.0, bytes))
.unwrap_or_else(|| "—".to_string())
}
fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) {
let options = addable_playlists(state);
let loading = matches!(&state.playlists.list, None | Some(Loadable::Loading));
let rows = if loading {
1
} else if options.is_empty() {
2
} else {
options.len() + 1
};
let height = (rows as u16 + 4)
.min(frame.area().height.saturating_sub(2))
.max(6);
let area = centered(frame.area(), 44, height);
let block = Block::bordered()
.title(" Add to playlist ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let mut lines: Vec<Line> = Vec::new();
if loading {
lines.push(Line::styled("loading playlists…", theme::dim()));
} else if matches!(&state.playlists.list, Some(Loadable::Failed(_))) {
lines.push(Line::styled("playlist list unavailable", theme::dim()));
lines.push(Line::styled("+ New playlist…", theme::accent_for(state)));
} else if options.is_empty() {
lines.push(Line::styled("no playlists yet", theme::dim()));
lines.push(Line::styled("+ New playlist…", theme::accent_for(state)));
} else {
for (_, title) in &options {
lines.push(Line::raw(title.clone()));
}
lines.push(Line::styled("+ New playlist…", theme::accent_for(state)));
}
let selected_line = if loading {
0
} else if options.is_empty() {
lines.len().saturating_sub(1)
} else {
cursor.min(options.len())
};
let visible = usize::from(list_area.height.max(1));
let first = selected_line
.saturating_sub(visible / 2)
.min(lines.len().saturating_sub(visible));
for (index, line) in lines.into_iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: list_area.x,
y: list_area.y + (index - first) as u16,
width: list_area.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row);
if index == selected_line {
frame
.buffer_mut()
.set_style(row, theme::tab_active_for(state));
}
}
let footer_text = if loading {
format!("♪ {track_title} · loading playlists · esc close")
} else {
format!("♪ {track_title} · enter add/create · esc close")
};
frame.render_widget(
Paragraph::new(Line::styled(footer_text, theme::dim())).alignment(Alignment::Center),
footer,
);
}
fn draw_name_entry(
frame: &mut Frame,
state: &AppState,
input: &crate::app::input::LineEdit,
busy: bool,
) {
let area = centered(frame.area(), 44, 7);
let block = Block::bordered()
.title(" New playlist ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [field, _, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let name_block = Block::bordered()
.title("Name")
.border_style(theme::strong_border_for(state));
let name_inner = name_block.inner(field);
frame.render_widget(name_block, field);
let spans = super::line_edit_spans(input, usize::from(name_inner.width));
frame.render_widget(Paragraph::new(Line::from(spans)), name_inner);
let hint = if busy {
Line::styled("creating…", theme::accent_for(state))
} else {
Line::styled("enter create · esc back", theme::dim())
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(rect);
rect
}