Added connected devices. Improved logging. UI fixes

This commit is contained in:
Ultradesu
2026-06-10 23:30:03 +01:00
parent bcee68eb4e
commit 02a396c146
25 changed files with 2540 additions and 314 deletions
+110 -46
View File
@@ -39,7 +39,11 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
if area.height == 0 {
return;
}
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
let middle = Rect {
y: area.y + area.height / 2,
height: 1,
..area
};
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
}
@@ -86,21 +90,29 @@ fn draw_tile(
let inner = block.inner(tile);
frame.render_widget(block, tile);
let art_area = Rect { height: ART_CELL_HEIGHT.min(inner.height), ..inner };
let art_area = Rect {
height: ART_CELL_HEIGHT.min(inner.height),
..inner
};
draw_art(frame, art_area, art_state);
if inner.height > ART_CELL_HEIGHT {
let name_area = Rect { y: inner.y + ART_CELL_HEIGHT, height: 1, ..inner };
frame.render_widget(
Paragraph::new(Line::raw(title.to_string())),
name_area,
);
let name_area = Rect {
y: inner.y + ART_CELL_HEIGHT,
height: 1,
..inner
};
frame.render_widget(Paragraph::new(Line::raw(title.to_string())), name_area);
if selected {
frame.buffer_mut().set_style(name_area, theme::tab_active());
}
}
if inner.height > ART_CELL_HEIGHT + 1 {
let meta_area = Rect { y: inner.y + ART_CELL_HEIGHT + 1, height: 1, ..inner };
let meta_area = Rect {
y: inner.y + ART_CELL_HEIGHT + 1,
height: 1,
..inner
};
frame.render_widget(
Paragraph::new(Line::styled(meta.to_string(), theme::dim())),
meta_area,
@@ -126,7 +138,6 @@ fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option<String>, se
}
}
// ---------------------------------------------------------------------------
// Scrollable content plan: a vertical list of items with known heights; the
// viewport is scrolled so the cursor's item stays centered.
@@ -137,7 +148,9 @@ enum PlanItem {
Header(String),
Gap,
/// Selectable track row; the payload is the cursor index it represents.
Track { cursor_index: usize },
Track {
cursor_index: usize,
},
/// One row of release tiles (display-order positions).
TileRow(Vec<usize>),
/// One release as a table row (display-order position).
@@ -147,7 +160,9 @@ enum PlanItem {
impl PlanItem {
fn height(&self) -> u16 {
match self {
PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. }
PlanItem::Header(_)
| PlanItem::Gap
| PlanItem::Track { .. }
| PlanItem::TableRow(_) => 1,
PlanItem::TileRow(_) => TILE_HEIGHT,
}
@@ -239,23 +254,30 @@ fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) {
let first = (global.selected / visible_rows) * visible_rows;
let last = (first + visible_rows).min(global.artists.len());
let rows = global.artists[first..last].iter().enumerate().map(|(offset, artist)| {
let index = first + offset;
let style = if index == global.selected {
theme::tab_active()
} else {
Style::new()
};
Row::new(vec![
artist.name.clone(),
artist.release_count.to_string(),
artist.track_count.to_string(),
])
.style(style)
});
let rows = global.artists[first..last]
.iter()
.enumerate()
.map(|(offset, artist)| {
let index = first + offset;
let style = if index == global.selected {
theme::tab_active()
} else {
Style::new()
};
Row::new(vec![
artist.name.clone(),
artist.release_count.to_string(),
artist.track_count.to_string(),
])
.style(style)
});
let table = Table::new(
rows,
[Constraint::Min(24), Constraint::Length(9), Constraint::Length(7)],
[
Constraint::Min(24),
Constraint::Length(9),
Constraint::Length(7),
],
)
.header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header()));
frame.render_widget(table, inner);
@@ -294,9 +316,16 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
.areas(header_area);
draw_art(
frame,
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
Rect {
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.image_url.as_ref()),
);
let mut about = format!("{} releases", detail.releases.len());
if !detail.featured_tracks.is_empty() {
about.push_str(&format!(" · appears on {}", detail.featured_tracks.len()));
}
let info = vec![
Line::default(),
Line::styled(detail.name.clone(), theme::header()),
@@ -308,21 +337,33 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
),
theme::dim(),
),
Line::styled(format!("{} releases", detail.releases.len()), theme::dim()),
Line::styled(about, theme::dim()),
];
frame.render_widget(Paragraph::new(info), info_area);
// Scrollable content: top tracks, then releases grouped by type.
// Scrollable content: top tracks, releases grouped by type, then the
// tracks this artist is featured on.
let tracks = detail.top_tracks.len();
let releases_len = detail.releases.len();
let featured_len = detail.featured_tracks.len();
let mut items = Vec::new();
let mut cursor_item = None;
if tracks + releases_len + featured_len == 0 {
return centered_line(
frame,
content_area,
Line::styled("nothing here yet", theme::dim()),
);
}
if tracks > 0 {
items.push(PlanItem::Header("Top tracks".to_string()));
for index in 0..tracks {
if cursor == index {
cursor_item = Some(items.len());
}
items.push(PlanItem::Track { cursor_index: index });
items.push(PlanItem::Track {
cursor_index: index,
});
}
items.push(PlanItem::Gap);
}
@@ -353,18 +394,39 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
}
items.push(PlanItem::Gap);
}
if featured_len > 0 {
items.push(PlanItem::Header(format!("Appears on ({featured_len})")));
for index in 0..featured_len {
let flat = tracks + releases_len + index;
if cursor == flat {
cursor_item = Some(items.len());
}
items.push(PlanItem::Track { cursor_index: flat });
}
items.push(PlanItem::Gap);
}
let display_order = crate::app::state::release_display_order(&detail.releases);
render_plan(frame, content_area, state, &items, cursor_item, &mut |frame, rect, item| {
match item {
render_plan(
frame,
content_area,
state,
&items,
cursor_item,
&mut |frame, rect, item| match item {
PlanItem::Track { cursor_index } => {
let track = &detail.top_tracks[*cursor_index];
let (track, number) = if *cursor_index < tracks {
(&detail.top_tracks[*cursor_index], cursor_index + 1)
} else {
let offset = cursor_index - tracks - releases_len;
(&detail.featured_tracks[offset], offset + 1)
};
super::track_row(
frame,
rect,
state,
track,
(cursor_index + 1).to_string(),
number.to_string(),
cursor == *cursor_index,
);
}
@@ -374,7 +436,8 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
let tile = Rect {
x: rect.x + column as u16 * TILE_WIDTH,
y: rect.y,
width: TILE_WIDTH.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)),
width: TILE_WIDTH
.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)),
height: rect.height,
};
if tile.width < 3 {
@@ -405,8 +468,8 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
);
}
_ => unreachable!("headers and gaps are rendered by render_plan"),
}
});
},
);
}
fn release_tile_meta(release: &ReleaseCard) -> String {
@@ -491,7 +554,10 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
.areas(header_area);
draw_art(
frame,
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
Rect {
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.cover_url.as_ref()),
);
@@ -504,7 +570,11 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
Line::raw(artists.join(", ")),
Line::default(),
Line::styled(
format!("{}{year} · {} tracks", detail.release_type, detail.tracks.len()),
format!(
"{}{year} · {} tracks",
detail.release_type,
detail.tracks.len()
),
theme::dim(),
),
];
@@ -599,12 +669,6 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
} else {
Span::raw(" ")
};
let tech = track.tech_label_short();
let right = if tech.is_empty() {
track.duration_label()
} else {
format!("{tech} · {}", track.duration_label())
};
rows.push((
Line::from(vec![
heart,
@@ -614,7 +678,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
theme::dim(),
),
]),
Some(right),
Some(super::track_meta_suffix(track, true)),
Some(index),
));
index += 1;
+63 -25
View File
@@ -25,34 +25,68 @@ fn draw_form(frame: &mut Frame, form: &LoginForm) {
// 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);
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);
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_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(
@@ -68,8 +102,8 @@ fn draw_form(frame: &mut Frame, form: &LoginForm) {
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 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()
@@ -134,7 +168,11 @@ fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
}
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 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())
+22 -14
View File
@@ -24,13 +24,13 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
};
let visible = usize::from(inner.height.max(1));
let skip = if logs.follow { 0 } else { logs.scroll_from_end };
let (entries, matched) = buffer.window(level, skip, visible);
if entries.is_empty() {
let selected = if logs.follow { None } else { logs.selected_seq };
let view = buffer.view(level, selected, visible);
if view.entries.is_empty() {
return centered(frame, inner, "no log entries at this level yet");
}
for (row_index, entry) in entries.iter().enumerate() {
for (row_index, entry) in view.entries.iter().enumerate() {
let row = Rect {
x: inner.x,
y: inner.y + row_index as u16,
@@ -44,6 +44,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
Span::raw(entry.message.clone()),
]);
frame.render_widget(Paragraph::new(line), row);
if view.cursor_row == Some(row_index) {
frame.buffer_mut().set_style(row, theme::tab_active());
}
}
// Footer hint with position info while scrolled back.
@@ -55,7 +58,10 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
};
frame.render_widget(
Paragraph::new(Line::styled(
format!("{skip} of {matched} · shift-g: follow · v: level "),
format!(
"{} of {} · enter: details · shift-g: follow · v: level ",
view.from_end, view.matched
),
theme::tab_active(),
))
.alignment(Alignment::Right),
@@ -68,19 +74,23 @@ fn centered(frame: &mut Frame, area: Rect, text: &str) {
if area.height == 0 {
return;
}
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
let middle = Rect {
y: area.y + area.height / 2,
height: 1,
..area
};
frame.render_widget(
Paragraph::new(Line::styled(text.to_string(), theme::dim()))
.alignment(Alignment::Center),
Paragraph::new(Line::styled(text.to_string(), theme::dim())).alignment(Alignment::Center),
middle,
);
}
fn level_span(level: tracing::Level) -> Span<'static> {
match level {
tracing::Level::ERROR => {
Span::styled("ERROR", Style::new().fg(Color::Red).add_modifier(Modifier::BOLD))
}
tracing::Level::ERROR => Span::styled(
"ERROR",
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
),
tracing::Level::WARN => Span::styled("WARN ", Style::new().fg(Color::Yellow)),
tracing::Level::INFO => Span::styled("INFO ", theme::accent()),
tracing::Level::DEBUG => Span::styled("DEBUG", theme::dim()),
@@ -90,7 +100,5 @@ fn level_span(level: tracing::Level) -> Span<'static> {
/// `furumi_tui::app::update` → `app::update` — the crate prefix is noise.
fn short_target(target: &str) -> &str {
target
.split_once("::")
.map_or(target, |(_, rest)| rest)
target.split_once("::").map_or(target, |(_, rest)| rest)
}
+181 -44
View File
@@ -3,13 +3,14 @@ mod global;
mod login;
mod logs;
mod playlists;
mod popup;
pub mod theme;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
use crate::app::state::{AppState, Screen, Tab};
use crate::config::keymap::Keymap;
@@ -38,6 +39,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.help_visible {
draw_help(frame, keymap);
}
popup::draw(frame, state);
}
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -75,12 +77,7 @@ pub(crate) fn track_row(
]);
frame.render_widget(Paragraph::new(line), area);
let tech = track.tech_label_short();
let right = if tech.is_empty() || area.width < 60 {
track.duration_label()
} else {
format!("{tech} · {}", track.duration_label())
};
let right = track_meta_suffix(track, area.width >= 60);
frame.render_widget(
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
area,
@@ -90,6 +87,38 @@ pub(crate) fn track_row(
}
}
pub(crate) fn track_meta_suffix(
track: &crate::api::models::TrackItem,
include_tech: bool,
) -> String {
let has_tech = track.audio_format.is_some()
|| track.audio_bitrate.is_some()
|| track.file_size_bytes.is_some();
if !include_tech || !has_tech {
return track.duration_label();
}
let format = track
.audio_format
.as_deref()
.map(|value| value.to_ascii_uppercase())
.unwrap_or_default();
let format: String = format.chars().take(4).collect();
let bitrate = track
.audio_bitrate
.map(|value| format!("{value}k"))
.unwrap_or_default();
let size = track
.file_size_bytes
.map(|bytes| format!("{:.1}MB", bytes as f64 / 1_048_576.0))
.unwrap_or_default();
format!(
"{format:<4} {bitrate:>5} {size:>8} · {:>5}",
track.duration_label()
)
}
/// Interactive queue: its own cursor, enter plays the selected track and
/// already-played tracks stay listed, greyed out.
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -105,7 +134,11 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
frame.render_widget(block, area);
if player.queue.is_empty() {
let middle = Rect { y: inner.y + inner.height / 2, height: 1, ..inner };
let middle = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(
Paragraph::new(Line::styled(
"queue is empty — open a track and press enter",
@@ -122,9 +155,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let first = cursor
.saturating_sub(visible / 2)
.min(player.queue.len().saturating_sub(visible));
let played_style = Style::new()
.fg(Color::DarkGray)
.bg(Color::Rgb(28, 28, 32));
let played_style = Style::new().fg(Color::DarkGray).bg(Color::Rgb(28, 28, 32));
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: inner.x,
@@ -207,16 +238,36 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
));
}
} else {
spans.push(Span::styled(
format!(" {}%", player.volume),
theme::dim(),
));
spans.push(Span::styled(format!(" {}%", player.volume), theme::dim()));
}
// Keep a gap between the flags and the username block to the right.
spans.push(Span::raw(" "));
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);
@@ -227,6 +278,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
// 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()),
@@ -234,12 +287,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, user_area] = Layout::horizontal([
let [title_area, right_area, device_area, user_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),
@@ -271,7 +329,6 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
frame.render_widget(Paragraph::new(Line::from(spans)), title_area);
frame.render_widget(Paragraph::new(center), right_area);
if state.cmdline.active {
// Vim-style command line takes over the message row.
let line = Line::from(vec![
@@ -280,6 +337,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
Span::styled("", theme::accent()),
]);
frame.render_widget(Paragraph::new(line), message_row);
draw_version(frame, message_row);
return;
}
@@ -294,6 +352,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
},
};
frame.render_widget(Paragraph::new(message), message_row);
draw_version(frame, message_row);
if let Some(pending) = &state.pending_keys {
let pending = Paragraph::new(Line::styled(format!("{pending}"), theme::header()))
@@ -302,36 +361,115 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
}
}
fn draw_version(frame: &mut Frame, area: Rect) {
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
frame.render_widget(
Paragraph::new(Line::styled(version, theme::dim())).alignment(Alignment::Right),
area,
);
}
/// Help window: bindings merged per action (j / down on one row), grouped
/// into titled sections and laid out in two balanced columns.
fn draw_help(frame: &mut Frame, keymap: &Keymap) {
let entries = keymap.help_entries();
let height = (entries.len() as u16 + 4).min(frame.area().height.saturating_sub(2));
let width = 56.min(frame.area().width.saturating_sub(2));
use crate::app::action::{Action, Category};
use crate::config::keymap::KeyContext;
struct MergedRow {
keys: Vec<String>,
action: Action,
context: KeyContext,
}
let mut merged: Vec<MergedRow> = Vec::new();
for (keys, action, context) in keymap.help_entries() {
match merged
.iter_mut()
.find(|row| row.action == action && row.context == context)
{
Some(row) => row.keys.push(keys),
None => merged.push(MergedRow {
keys: vec![keys],
action,
context,
}),
}
}
// One block of lines per category: section header + its rows.
let mut blocks: Vec<Vec<Line>> = Vec::new();
for category in Category::ALL {
let rows: Vec<&MergedRow> = merged
.iter()
.filter(|row| row.action.category() == category)
.collect();
if rows.is_empty() {
continue;
}
let mut lines = vec![Line::styled(category.title(), theme::header())];
for row in rows {
let keys = row.keys.join(" / ");
let context = if row.context == KeyContext::Global {
String::new()
} else {
format!(" [{}]", row.context.label())
};
let command = row.action.command_hint().unwrap_or("");
lines.push(Line::from(vec![
Span::styled(format!("{keys:<13}"), theme::accent()),
Span::raw(format!(
"{:<24}",
format!("{}{context}", row.action.describe())
)),
Span::styled(command.to_string(), theme::accent()),
]));
}
lines.push(Line::default());
blocks.push(lines);
}
// Balance the blocks across two columns.
let total: usize = blocks.iter().map(Vec::len).sum();
let mut left: Vec<Line> = Vec::new();
let mut right: Vec<Line> = Vec::new();
for block in blocks {
if left.len() < total.div_ceil(2) {
left.extend(block);
} else {
right.extend(block);
}
}
let column_height = left.len().max(right.len()) as u16;
let width = 110.min(frame.area().width.saturating_sub(2));
let height = (column_height + 4).min(frame.area().height.saturating_sub(2));
let area = centered_rect(frame.area(), width, height);
let rows = entries.into_iter().map(|(keys, description, context)| {
Row::new(vec![
Span::styled(keys, theme::accent()),
Span::raw(description),
Span::styled(context.label(), theme::dim()),
])
});
let table = Table::new(
rows,
[
Constraint::Length(12),
Constraint::Min(20),
Constraint::Length(9),
],
)
.header(Row::new(vec!["keys", "action", "context"]).style(theme::header()))
.block(
Block::bordered()
.title(" Keybindings ")
.title_style(theme::header()),
);
let block = Block::bordered()
.title(" Keybindings & commands ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(table, area);
frame.render_widget(block, area);
let [columns_area, footer] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
let [left_area, _, right_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(2),
Constraint::Percentage(50),
])
.areas(columns_area);
frame.render_widget(Paragraph::new(left), left_area);
frame.render_widget(Paragraph::new(right), right_area);
frame.render_widget(
Paragraph::new(Line::styled(
": opens the command line · full forms: :seek +30|1:30 · :volume 0-100 · :repeat off|one|all · :logs [level]",
theme::dim(),
))
.alignment(Alignment::Center),
footer,
);
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
@@ -343,4 +481,3 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
.areas(rect);
rect
}
+23 -4
View File
@@ -29,7 +29,11 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
if area.height == 0 {
return;
}
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
let middle = Rect {
y: area.y + area.height / 2,
height: 1,
..area
};
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
}
@@ -47,7 +51,11 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
);
}
_ => {
return centered_line(frame, inner, Line::styled("loading playlists…", theme::dim()));
return centered_line(
frame,
inner,
Line::styled("loading playlists…", theme::dim()),
);
}
};
if list.is_empty() {
@@ -125,7 +133,11 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
};
if tracks.is_empty() {
return centered_line(frame, inner, Line::styled("no tracks here yet", theme::dim()));
return centered_line(
frame,
inner,
Line::styled("no tracks here yet", theme::dim()),
);
}
let visible = usize::from(inner.height.max(1));
@@ -139,6 +151,13 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
width: inner.width,
height: 1,
};
track_row(frame, row, state, track, (index + 1).to_string(), index == cursor);
track_row(
frame,
row,
state,
track,
(index + 1).to_string(),
index == cursor,
);
}
}
+247
View File
@@ -0,0 +1,247 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use super::theme;
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
pub fn draw(frame: &mut Frame, state: &AppState) {
match state.popup.as_ref() {
Some(Popup::AddToPlaylist { track, cursor }) => {
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::LogDetail(entry)) => draw_log_detail(frame, entry),
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);
let block = Block::bordered()
.title(" Connected devices ")
.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);
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());
}
}
}
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())
};
frame.render_widget(Paragraph::new(hint).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);
let area = centered(frame.area(), width, height);
let block = Block::bordered()
.title(format!(" Log entry — {} {} ", entry.time, entry.level))
.title_style(theme::header())
.border_style(theme::accent());
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_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) {
let options = addable_playlists(state);
let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_)));
let rows = 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())
.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 lines: Vec<Line> = vec![Line::styled("+ New playlist…", theme::accent())];
if loading {
lines.push(Line::styled("loading playlists…", theme::dim()));
} else if options.is_empty() {
lines.push(Line::styled("no playlists yet", theme::dim()));
} else {
for (_, title) in &options {
lines.push(Line::raw(title.clone()));
}
}
let visible = usize::from(list_area.height.max(1));
let first = cursor
.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 == cursor {
frame.buffer_mut().set_style(row, theme::tab_active());
}
}
frame.render_widget(
Paragraph::new(Line::styled(
format!("{track_title} · enter add · esc close"),
theme::dim(),
))
.alignment(Alignment::Center),
footer,
);
}
fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) {
let area = centered(frame.area(), 44, 7);
let block = Block::bordered()
.title(" New playlist ")
.title_style(theme::header())
.border_style(theme::accent());
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::accent());
let name_inner = name_block.inner(field);
frame.render_widget(name_block, field);
let width = usize::from(name_inner.width.saturating_sub(1));
let mut shown: String = input
.chars()
.skip(input.chars().count().saturating_sub(width))
.collect();
shown.push('█');
frame.render_widget(Paragraph::new(shown), name_inner);
let hint = if busy {
Line::styled("creating…", theme::accent())
} 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
}