1584 lines
52 KiB
Rust
1584 lines
52 KiB
Rust
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
|
||
}
|