Added connected devices. Improved logging. UI fixes
This commit is contained in:
+110
-46
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user