init federation

This commit is contained in:
Ultradesu
2026-07-16 20:29:51 +03:00
parent 98525718d0
commit b1f8f4cb01
33 changed files with 6417 additions and 4019 deletions
+138
View File
@@ -0,0 +1,138 @@
//! The Federation tab: settings rows on top, a live status block below.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use super::theme;
use crate::app::state::{AppState, FedRow};
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::bordered()
.title(" Federation ")
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
let rows_height = FedRow::ALL.len() as u16;
let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
let settings = &state.federation.settings;
let on_off = |on: bool| if on { "on" } else { "off" };
for (index, row) in FedRow::ALL.iter().enumerate() {
let (label, value) = match row {
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
FedRow::NetworkId => (
"Network ID (shared secret)",
if settings.network_id.is_empty() {
"(not set — press enter)".to_string()
} else {
settings.network_id.clone()
},
),
FedRow::SaveOnListen => (
"Save federated tracks to the library on listen",
on_off(settings.save_on_listen).to_string(),
),
FedRow::SyncNow => ("Publish the library now", "".to_string()),
FedRow::ShowTicket => ("Show my connection ticket", "".to_string()),
FedRow::Connect => ("Connect to a peer by ticket…", "".to_string()),
};
let selected = index == state.federation.cursor;
let rect = Rect {
x: rows_area.x,
y: rows_area.y + index as u16,
width: rows_area.width,
height: 1,
};
if rect.y >= rows_area.y + rows_area.height {
break;
}
let marker = if selected { "" } else { " " };
let label_width = 48usize;
let line = Line::from(vec![
Span::styled(marker, theme::accent()),
Span::styled(format!("{label:<label_width$}"), if selected {
theme::accent()
} else {
ratatui::style::Style::default()
}),
Span::styled(value, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), rect);
}
draw_status(frame, status_area, state);
}
fn status_line(label: &str, value: String) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<22}"), theme::dim()),
Span::raw(value),
])
}
fn short_id(id: &str) -> String {
id.chars().take(12).collect::<String>() + ""
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
match &state.federation.status {
None => lines.push(Line::styled("loading…", theme::dim())),
Some(status) if !status.running => {
lines.push(status_line("Node", "stopped".to_string()));
if let Some(error) = &status.last_error {
lines.push(status_line("Error", error.clone()));
}
lines.push(Line::default());
lines.push(Line::styled(
"Enable federation and set a network id — every instance using the",
theme::dim(),
));
lines.push(Line::styled(
"same id (furumi TUI or furumi-fd) finds the others automatically.",
theme::dim(),
));
}
Some(status) => {
lines.push(status_line("Node", "running".to_string()));
lines.push(status_line("Network", status.network.clone()));
lines.push(status_line("Endpoint ID", status.endpoint_id.clone()));
let peers = if status.connected_peers.is_empty() {
"none yet".to_string()
} else {
let names: Vec<String> =
status.connected_peers.iter().map(|p| short_id(p)).collect();
format!("{}{}", status.connected_peers.len(), names.join(", "))
};
lines.push(status_line("Connected peers", peers));
lines.push(status_line(
"Known contacts",
status.known_contacts.to_string(),
));
lines.push(status_line(
"Published items",
status.published_items.to_string(),
));
lines.push(status_line(
"Last sync",
status
.last_sync
.clone()
.unwrap_or_else(|| "not yet".to_string()),
));
if let Some(error) = &status.last_error {
lines.push(status_line("Error", error.clone()));
}
}
}
frame.render_widget(Paragraph::new(lines), area);
}
+58 -21
View File
@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{art, theme};
use crate::api::models::{ArtistCard, ReleaseCard};
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
use crate::app::state::{
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
@@ -315,7 +315,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
draw_tile(
frame,
tile,
tile_art(state, artist.image_url.as_ref()),
tile_art(state, artist.image_path.as_ref()),
&artist.name,
&artist_tile_meta(artist),
index == global.selected,
@@ -395,7 +395,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.image_url.as_ref()),
header_art(state, detail.image_path.as_ref()),
);
let mut about = format!("{} releases", detail.releases.len());
if !detail.featured_tracks.is_empty() {
@@ -536,7 +536,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
draw_tile(
frame,
tile,
tile_art(state, release.cover_url.as_ref()),
tile_art(state, release.cover_path.as_ref()),
&release.title,
&release_tile_meta(release),
cursor == tracks + position,
@@ -648,13 +648,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.cover_url.as_ref()),
header_art(state, detail.cover_path.as_ref()),
);
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect();
let mut info = vec![
let info = vec![
Line::default(),
Line::styled(detail.title.clone(), theme::header()),
Line::raw(artists.join(", ")),
@@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
theme::dim(),
),
];
if !uploaders.is_empty() {
info.push(Line::styled(
format!("uploaded by {}", uploaders.join(", ")),
theme::dim(),
));
}
frame.render_widget(Paragraph::new(info), info_area);
// Track list with centered scrolling.
@@ -719,15 +712,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
}
let inner = bordered(frame, area, title);
let Some(results) = &search.results else {
let hint = if search.query.is_empty() {
"type to search artists, releases and tracks"
} else {
"searching…"
};
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
let empty_results = SearchResults::default();
let results = match &search.results {
Some(results) => results,
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
None => {
let hint = if search.query.is_empty() {
"type to search artists, releases and tracks"
} else {
"searching…"
};
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
}
};
if results.len() == 0 {
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
}
@@ -784,6 +782,45 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
index += 1;
}
}
// Tracks found on the federated network, marked with the owning peer.
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
if !rows.is_empty() {
rows.push((Line::default(), None, None));
}
let header = if state.search.fed_loading {
"Federation · searching…"
} else {
"Federation"
};
rows.push((Line::styled(header, theme::header()), None, None));
for fed in &state.search.fed_tracks {
let origin = if fed.own {
"your library".to_string()
} else {
format!("peer {}", fed.owner_short())
};
let mut meta = fed.duration_label();
if let Some(year) = fed.year {
if !meta.is_empty() {
meta.push_str(" · ");
}
meta.push_str(&year.to_string());
}
rows.push((
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(fed.title.clone()),
Span::styled(
format!(" {} · {}", fed.artist_line(), origin),
theme::dim(),
),
]),
Some(meta),
Some(index),
));
index += 1;
}
}
let cursor_row = rows
.iter()
-230
View File
@@ -1,230 +0,0 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
use super::theme;
use crate::app::state::{LoginField, LoginForm, LoginMode};
pub fn draw(frame: &mut Frame, form: &LoginForm) {
match form.mode {
LoginMode::Form => draw_form(frame, form),
LoginMode::SsoPending => draw_sso_pending(frame, form),
}
}
fn draw_form(frame: &mut Frame, form: &LoginForm) {
let area = centered(frame.area(), 52, 19);
let block = Block::bordered()
.title(" Sign in to furumi ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
// SSO is the primary path: server URL + SSO button up top, the rarely
// used password fallback below a separator.
let [
server,
sso_button,
separator,
username,
password,
signin_button,
message,
hint,
] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
draw_field(
frame,
server,
"Server URL",
&form.server_url,
false,
form.focus == LoginField::ServerUrl,
);
draw_button(
frame,
sso_button,
"[ Continue with SSO ]",
form.focus == LoginField::SsoButton,
);
frame.render_widget(
Paragraph::new(Line::styled("── or sign in with password ──", theme::dim()))
.alignment(Alignment::Center),
separator,
);
draw_field(
frame,
username,
"Username",
&form.username,
false,
form.focus == LoginField::Username,
);
draw_field(
frame,
password,
"Password",
&form.password,
true,
form.focus == LoginField::Password,
);
draw_button(
frame,
signin_button,
"[ Sign in ]",
form.focus == LoginField::SignInButton,
);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled(
"tab/↑↓ move · enter submit · ctrl-c quit",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
// The URL stays on ONE line (wrapping breaks copy-paste); the dialog is
// as wide as the terminal allows and ctrl-l copies the full link.
let width =
(form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40));
let area = centered(frame.area(), width, 14.min(frame.area().height));
let block = Block::bordered()
.title(" Continue with SSO ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
let [steps, url_label, url, paste, message, hint] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
let lines = if let Some(port) = form.sso_port {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::from(vec![
Span::raw("2. Sign-in completes here automatically "),
Span::styled(format!("(waiting on 127.0.0.1:{port})"), theme::dim()),
]),
Line::raw("3. If it doesn't, paste the code from the page below."),
]
} else {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::raw("2. Copy the code shown on the final page."),
Line::raw("3. Paste it below and press Enter."),
]
};
frame.render_widget(Paragraph::new(lines), steps);
frame.render_widget(
Paragraph::new(Line::styled(
"If the browser didn't open — ctrl-l copies this link, ctrl-o retries:",
theme::dim(),
)),
url_label,
);
// One line, never wrapped: a wrapped URL copies with a line break and
// stops working. If it doesn't fit, ctrl-l still copies it whole.
frame.render_widget(
Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())),
url,
);
draw_field(frame, paste, "Link or code", &form.sso_paste, false, true);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled(
"enter submit · ctrl-l copy link · esc back · ctrl-c quit",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) {
let border = if focused {
theme::accent()
} else {
theme::dim()
};
let block = Block::bordered().title(label).border_style(border);
let shown = if mask {
"".repeat(value.chars().count())
} else {
value.to_string()
};
// Keep the tail visible when the value overflows the field.
let width = block.inner(area).width.saturating_sub(1) as usize;
let mut text: String = shown
.chars()
.skip(shown.chars().count().saturating_sub(width))
.collect();
if focused {
text.push('█');
}
frame.render_widget(Paragraph::new(text).block(block), area);
}
fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) {
let style = if focused {
theme::tab_active()
} else {
theme::dim()
};
frame.render_widget(
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center),
area,
);
}
fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) {
let line = if form.busy {
Line::styled("signing in…", theme::accent())
} else if let Some(error) = &form.error {
Line::styled(error.clone(), Style::new().fg(Color::Red))
} else {
Line::default()
};
frame.render_widget(
Paragraph::new(line)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center),
area,
);
}
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
}
+10 -58
View File
@@ -1,6 +1,6 @@
pub mod art;
mod federation;
mod global;
mod login;
mod logs;
mod playlists;
mod popup;
@@ -12,14 +12,10 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope};
use crate::app::state::{AppState, Tab, TrackSelectionScope};
use crate::config::keymap::Keymap;
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.screen == Screen::Login {
login::draw(frame, &state.login);
return;
}
let [tabs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
@@ -32,6 +28,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
Tab::Global => global::draw(frame, main_area, state),
Tab::Playlists => playlists::draw(frame, main_area, state),
Tab::Queue => draw_queue(frame, main_area, state),
Tab::Federation => federation::draw(frame, main_area, state),
Tab::Logs => logs::draw(frame, main_area, state),
}
draw_status(frame, status_area, state);
@@ -60,7 +57,7 @@ pub(crate) fn track_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
index_label: String,
selected: bool,
visual_selected: bool,
@@ -92,7 +89,7 @@ pub(crate) fn track_row(
}
pub(crate) fn track_meta_suffix(
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
include_tech: bool,
) -> String {
let has_tech = track.audio_format.is_some()
@@ -201,8 +198,8 @@ fn format_secs(secs: f64) -> String {
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(track) = &player.current {
if player.playing {
if let Some(track) = &player.current
&& player.playing {
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
@@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
));
}
}
}
if width >= 80 {
let volume_cells = usize::from(player.volume / 10);
spans.extend([
@@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
Line::from(spans)
}
fn truncate_chars(value: &str, max: usize) -> String {
let mut out: String = value.chars().take(max).collect();
if value.chars().count() > max {
out.push('…');
}
out
}
fn device_status_line(state: &AppState) -> Line<'static> {
if state.devices.is_playback_device() {
return Line::from(vec![Span::styled("playing here", theme::accent())]);
}
let name = state
.devices
.active_device_name()
.map(|name| truncate_chars(name, 26))
.unwrap_or_else(|| "remote device".to_string());
Line::from(vec![
Span::styled("controlling ", theme::dim()),
Span::styled(name, theme::accent()),
])
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let [player_row, message_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let player = &state.player;
// Layout: track title left, time/progress/flags centered, user right.
// The center block is built first and gets a fixed width; the title
// Layout: track title left, time/progress/flags on the right. The
// right block is built first and gets a fixed width; the title
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center_width = (center.width() as u16).min(area.width);
let device_line = device_status_line(state);
let device_width = (device_line.width() as u16).min(32);
let user_line = state.user.as_ref().map(|user| {
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(user.name.clone()),
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
let [title_area, right_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
Constraint::Length(device_width.saturating_add(2)),
Constraint::Length(user_width),
])
.areas(player_row);
frame.render_widget(
Paragraph::new(device_line).alignment(Alignment::Right),
device_area,
);
if let Some(user_line) = user_line {
frame.render_widget(
Paragraph::new(user_line).alignment(Alignment::Right),
user_area,
);
}
let mut spans = Vec::new();
match &player.current {
+1 -19
View File
@@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
} else {
Span::raw(" ")
};
let mut flags = Vec::new();
if !playlist.is_own {
if let Some(owner) = &playlist.owner_name {
flags.push(format!("by {owner}"));
}
}
if playlist.is_public {
flags.push("public".to_string());
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" {}", flags.join(" · "))
};
frame.render_widget(
Paragraph::new(Line::from(vec![
marker,
Span::raw(playlist.title.clone()),
Span::styled(suffix, theme::dim()),
])),
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
row,
);
frame.render_widget(
+130 -86
View File
@@ -4,8 +4,8 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use super::theme;
use crate::api::models::{ArtistRef, TrackItem};
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
use crate::library::models::{ArtistRef, TrackItem};
pub fn draw(frame: &mut Frame, state: &AppState) {
match state.popup.as_ref() {
@@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
draw_picker(frame, state, &track.title, *cursor)
}
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
Some(Popup::Edit {
title,
fields,
focus,
error,
..
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
Some(Popup::TrackInfo {
tracks,
cursor,
scroll,
}) => draw_track_info(frame, tracks, *cursor, *scroll),
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
None => {}
}
}
fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
let rows = state.devices.devices.len().max(1);
let height = (rows as u16 + 4)
.min(frame.area().height.saturating_sub(2))
.max(7);
let area = centered(frame.area(), 64, height);
/// One-line text entry on the Federation tab (network id / peer ticket).
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
let area = centered(frame.area(), 64, 5);
let block = Block::bordered()
.title(" Connected devices ")
.title(format!(" {title} "))
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [entry_area, hint_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
// Keep the tail visible when the value (a ticket) exceeds the width.
let visible: String = {
let width = usize::from(entry_area.width.saturating_sub(2));
let chars: Vec<char> = input.chars().collect();
let skip = chars.len().saturating_sub(width);
chars[skip..].iter().collect()
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(visible),
Span::styled("", theme::accent()),
])),
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, 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())
.border_style(theme::accent());
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,
);
}
/// 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,
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())
.border_style(theme::accent());
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 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);
if state.devices.devices.is_empty() {
frame.render_widget(
Paragraph::new(Line::styled("waiting for device poll…", theme::dim()))
.alignment(Alignment::Center),
list_area,
);
} else {
let visible = usize::from(list_area.height.max(1));
let cursor = cursor.min(state.devices.devices.len() - 1);
let first = cursor
.saturating_sub(visible / 2)
.min(state.devices.devices.len().saturating_sub(visible));
for (index, device) in state
.devices
.devices
.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 marker = if device.is_active {
Span::styled("", theme::accent())
} else {
Span::styled(" ", theme::dim())
};
let current = if device.is_current {
" · this TUI"
} else {
""
};
let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) {
" · switching"
} else {
""
};
let line = Line::from(vec![
marker,
Span::raw(device.name.clone()),
Span::styled(
format!(" · {}{current}{switching}", device.kind),
theme::dim(),
),
]);
frame.render_widget(Paragraph::new(line), row);
if index == cursor {
frame.buffer_mut().set_style(row, theme::tab_active());
}
for (index, field) in fields.iter().enumerate() {
let focused = index == focus;
let field_block = Block::bordered().title(field.label).border_style(if focused {
theme::accent()
} 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.saturating_sub(1));
let mut shown: String = field
.value
.chars()
.skip(field.value.chars().count().saturating_sub(width))
.collect();
if focused {
shown.push('█');
}
frame.render_widget(Paragraph::new(shown), field_inner);
}
let hint = if let Some(error) = &state.devices.poll_error {
Line::styled(format!("sync error: {error}"), theme::dim())
} else {
Line::styled("enter make active · esc close", theme::dim())
let footer = areas[areas.len() - 1];
let hint = match error {
Some(error) => Line::styled(error.to_string(), theme::accent()),
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, 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())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
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, 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);
@@ -203,7 +257,6 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
track.duration_seconds
),
),
field("Uploader", empty_dash(&track.uploader_name)),
field("Audio format", opt_string(track.audio_format.clone())),
field(
"Bitrate",
@@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
),
field("File size", file_size(track.file_size_bytes)),
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
field("Last.fm plays", opt_display(track.lastfm_playcount)),
field(
"Last.fm rating",
opt_map(track.lastfm_rating, |v| format!("{v:.3}")),
),
field(
"Last.fm updated",
opt_string(track.lastfm_updated_at.clone()),
),
field("Stream URL", empty_dash(&track.stream_url)),
field("Cover URL", opt_string(track.cover_url.clone())),
field("Plays", track.play_count.to_string()),
field("File path", empty_dash(&track.file_path)),
field("Cover path", opt_string(track.cover_path.clone())),
]
}