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>, 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>, 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::(), 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> = 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::()), ]), ]; 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::() )), ]), 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::()), ]), ]; 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 = 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> { 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::>() .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 { value .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "—".to_string()) } fn opt_display(value: Option) -> String { opt_map(value, |value| value.to_string()) } fn opt_map(value: Option, format: impl FnOnce(T) -> String) -> String { value.map(format).unwrap_or_else(|| "—".to_string()) } fn file_size(value: Option) -> 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 = 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 }