Init
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
use crate::art::ArtImage;
|
||||
|
||||
/// Render half-block art as ratatui text: one `▀` per cell, foreground =
|
||||
/// top pixel, background = bottom pixel.
|
||||
pub fn to_text(art: &ArtImage) -> Text<'static> {
|
||||
let mut lines = Vec::with_capacity(usize::from(art.height_cells));
|
||||
for y in 0..art.height_cells {
|
||||
let mut spans = Vec::with_capacity(usize::from(art.width_cells));
|
||||
for x in 0..art.width_cells {
|
||||
let (top, bottom) = art.cell(x, y);
|
||||
spans.push(Span::styled(
|
||||
"▀",
|
||||
Style::new()
|
||||
.fg(Color::Rgb(top[0], top[1], top[2]))
|
||||
.bg(Color::Rgb(bottom[0], bottom[1], bottom[2])),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
Text::from(lines)
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Row, Table};
|
||||
|
||||
use super::{art, theme};
|
||||
use crate::api::models::{ArtistCard, ReleaseCard};
|
||||
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,
|
||||
};
|
||||
use crate::art::cache_key;
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
match state.global.stack.last() {
|
||||
None => draw_grid(frame, area, state),
|
||||
Some(GlobalView::Artist { id, cursor }) => draw_artist(frame, area, state, *id, *cursor),
|
||||
Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor),
|
||||
Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_style() -> Style {
|
||||
Style::new().fg(Color::Red)
|
||||
}
|
||||
|
||||
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
inner
|
||||
}
|
||||
|
||||
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 };
|
||||
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
|
||||
}
|
||||
|
||||
fn tile_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> {
|
||||
state
|
||||
.art
|
||||
.get(&cache_key(url?, ART_CELL_WIDTH, ART_CELL_HEIGHT))
|
||||
}
|
||||
|
||||
fn header_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> {
|
||||
state
|
||||
.art
|
||||
.get(&cache_key(url?, ART_HEADER_WIDTH, ART_HEADER_HEIGHT))
|
||||
}
|
||||
|
||||
fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) {
|
||||
match art_state {
|
||||
Some(ArtState::Ready(image)) => {
|
||||
frame.render_widget(Paragraph::new(art::to_text(image)), area);
|
||||
}
|
||||
Some(ArtState::Loading) => centered_line(frame, area, Line::styled("…", theme::dim())),
|
||||
_ => centered_line(frame, area, Line::styled("♪", theme::dim())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bordered tile with artwork, a title line and a dim meta line. The
|
||||
/// selected tile gets a thick accent border and an inverted (filled)
|
||||
/// caption so it stands out in a large grid; the artwork stays untouched.
|
||||
fn draw_tile(
|
||||
frame: &mut Frame,
|
||||
tile: Rect,
|
||||
art_state: Option<&ArtState>,
|
||||
title: &str,
|
||||
meta: &str,
|
||||
selected: bool,
|
||||
) {
|
||||
let block = if selected {
|
||||
Block::bordered()
|
||||
.border_type(ratatui::widgets::BorderType::Thick)
|
||||
.border_style(theme::accent())
|
||||
} else {
|
||||
Block::bordered().border_style(theme::dim())
|
||||
};
|
||||
let inner = block.inner(tile);
|
||||
frame.render_widget(block, tile);
|
||||
|
||||
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,
|
||||
);
|
||||
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 };
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(meta.to_string(), theme::dim())),
|
||||
meta_area,
|
||||
);
|
||||
if selected {
|
||||
frame.buffer_mut().set_style(meta_area, theme::tab_active());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One selectable row: left content, optional right-aligned suffix, full-row
|
||||
/// highlight when selected.
|
||||
fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option<String>, selected: bool) {
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
if let Some(right) = right {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
||||
area,
|
||||
);
|
||||
}
|
||||
if selected {
|
||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scrollable content plan: a vertical list of items with known heights; the
|
||||
// viewport is scrolled so the cursor's item stays centered.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum PlanItem {
|
||||
/// Section header line.
|
||||
Header(String),
|
||||
Gap,
|
||||
/// Selectable track row; the payload is the cursor index it represents.
|
||||
Track { cursor_index: usize },
|
||||
/// One row of release tiles (display-order positions).
|
||||
TileRow(Vec<usize>),
|
||||
/// One release as a table row (display-order position).
|
||||
TableRow(usize),
|
||||
}
|
||||
|
||||
impl PlanItem {
|
||||
fn height(&self) -> u16 {
|
||||
match self {
|
||||
PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. }
|
||||
| PlanItem::TableRow(_) => 1,
|
||||
PlanItem::TileRow(_) => TILE_HEIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_offset(items: &[PlanItem], cursor_item: Option<usize>, viewport: u16) -> u16 {
|
||||
let total: u16 = items.iter().map(PlanItem::height).sum();
|
||||
if total <= viewport {
|
||||
return 0;
|
||||
}
|
||||
let Some(cursor_item) = cursor_item else {
|
||||
return 0;
|
||||
};
|
||||
let top: u16 = items[..cursor_item].iter().map(PlanItem::height).sum();
|
||||
let center = top + items[cursor_item].height() / 2;
|
||||
center
|
||||
.saturating_sub(viewport / 2)
|
||||
.min(total.saturating_sub(viewport))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artist grid (stack root)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let global = &state.global;
|
||||
let title = if global.total > 0 {
|
||||
format!(" Global — {} artists ", global.total)
|
||||
} else {
|
||||
" Global ".to_string()
|
||||
};
|
||||
let inner = bordered(frame, area, title);
|
||||
|
||||
if global.artists.is_empty() {
|
||||
let message = if let Some(error) = &global.error {
|
||||
Line::styled(error.clone(), error_style())
|
||||
} else if global.loading {
|
||||
Line::styled("loading artists…", theme::dim())
|
||||
} else {
|
||||
Line::styled("no artists in the library", theme::dim())
|
||||
};
|
||||
centered_line(frame, inner, message);
|
||||
return;
|
||||
}
|
||||
|
||||
match global.view {
|
||||
ViewMode::Tiles => draw_grid_tiles(frame, inner, state),
|
||||
ViewMode::Table => draw_grid_table(frame, inner, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_tile_meta(artist: &ArtistCard) -> String {
|
||||
format!("{} rel · {} trk", artist.release_count, artist.track_count)
|
||||
}
|
||||
|
||||
fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
|
||||
let global = &state.global;
|
||||
let columns = usize::from((inner.width / TILE_WIDTH).max(1));
|
||||
let visible_rows = usize::from((inner.height / TILE_HEIGHT).max(1));
|
||||
|
||||
let selected_row = global.selected / columns;
|
||||
let first_row = (selected_row / visible_rows) * visible_rows;
|
||||
let first_index = first_row * columns;
|
||||
let last_index = (first_index + visible_rows * columns).min(global.artists.len());
|
||||
|
||||
for (offset, artist) in global.artists[first_index..last_index].iter().enumerate() {
|
||||
let index = first_index + offset;
|
||||
let tile = Rect {
|
||||
x: inner.x + (offset % columns) as u16 * TILE_WIDTH,
|
||||
y: inner.y + (offset / columns) as u16 * TILE_HEIGHT,
|
||||
width: TILE_WIDTH,
|
||||
height: TILE_HEIGHT,
|
||||
};
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, artist.image_url.as_ref()),
|
||||
&artist.name,
|
||||
&artist_tile_meta(artist),
|
||||
index == global.selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) {
|
||||
let global = &state.global;
|
||||
let visible_rows = usize::from(inner.height.saturating_sub(1).max(1));
|
||||
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 table = Table::new(
|
||||
rows,
|
||||
[Constraint::Min(24), Constraint::Length(9), Constraint::Length(7)],
|
||||
)
|
||||
.header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header()));
|
||||
frame.render_widget(table, inner);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artist view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
|
||||
let loadable = state.artist_views.get(&id);
|
||||
let name = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail.name.clone(),
|
||||
_ => "Artist".to_string(),
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" Global ▸ {name} "));
|
||||
|
||||
let detail = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail,
|
||||
Some(Loadable::Failed(error)) => {
|
||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||
}
|
||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
||||
};
|
||||
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
|
||||
|
||||
// Header: artwork left, metadata right.
|
||||
let [art_area, _, info_area] = Layout::horizontal([
|
||||
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(header_area);
|
||||
draw_art(
|
||||
frame,
|
||||
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
|
||||
header_art(state, detail.image_url.as_ref()),
|
||||
);
|
||||
let info = vec![
|
||||
Line::default(),
|
||||
Line::styled(detail.name.clone(), theme::header()),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!(
|
||||
"{} tracks · {} plays",
|
||||
detail.total_track_count, detail.total_play_count
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
Line::styled(format!("{} releases", detail.releases.len()), theme::dim()),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Scrollable content: top tracks, then releases grouped by type.
|
||||
let tracks = detail.top_tracks.len();
|
||||
let mut items = Vec::new();
|
||||
let mut cursor_item = None;
|
||||
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::Gap);
|
||||
}
|
||||
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
||||
let mut position = 0;
|
||||
for (label, group) in release_groups(&detail.releases) {
|
||||
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
|
||||
match state.global.view {
|
||||
ViewMode::Tiles => {
|
||||
for chunk in group.chunks(columns) {
|
||||
let row: Vec<usize> = (position..position + chunk.len()).collect();
|
||||
if row.contains(&(cursor.wrapping_sub(tracks))) {
|
||||
cursor_item = Some(items.len());
|
||||
}
|
||||
items.push(PlanItem::TileRow(row));
|
||||
position += chunk.len();
|
||||
}
|
||||
}
|
||||
ViewMode::Table => {
|
||||
for _ in &group {
|
||||
if cursor == tracks + position {
|
||||
cursor_item = Some(items.len());
|
||||
}
|
||||
items.push(PlanItem::TableRow(position));
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
PlanItem::Track { cursor_index } => {
|
||||
let track = &detail.top_tracks[*cursor_index];
|
||||
super::track_row(
|
||||
frame,
|
||||
rect,
|
||||
state,
|
||||
track,
|
||||
(cursor_index + 1).to_string(),
|
||||
cursor == *cursor_index,
|
||||
);
|
||||
}
|
||||
PlanItem::TileRow(row) => {
|
||||
for (column, position) in row.iter().enumerate() {
|
||||
let release = &detail.releases[display_order[*position]];
|
||||
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)),
|
||||
height: rect.height,
|
||||
};
|
||||
if tile.width < 3 {
|
||||
break;
|
||||
}
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, release.cover_url.as_ref()),
|
||||
&release.title,
|
||||
&release_tile_meta(release),
|
||||
cursor == tracks + position,
|
||||
);
|
||||
}
|
||||
}
|
||||
PlanItem::TableRow(position) => {
|
||||
let release = &detail.releases[display_order[*position]];
|
||||
let year = release.year.map(|y| y.to_string()).unwrap_or_default();
|
||||
draw_row(
|
||||
frame,
|
||||
rect,
|
||||
Line::from(vec![
|
||||
Span::raw(release.title.clone()),
|
||||
Span::styled(format!(" {year}"), theme::dim()),
|
||||
]),
|
||||
Some(format!("{} trk", release.track_count)),
|
||||
cursor == tracks + position,
|
||||
);
|
||||
}
|
||||
_ => unreachable!("headers and gaps are rendered by render_plan"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn release_tile_meta(release: &ReleaseCard) -> String {
|
||||
match release.year {
|
||||
Some(year) => format!("{year} · {} trk", release.track_count),
|
||||
None => format!("{} trk", release.track_count),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render plan items into `area`, scrolled so the cursor item is visible.
|
||||
/// Headers and gaps are drawn here; everything else is delegated.
|
||||
fn render_plan(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
_state: &AppState,
|
||||
items: &[PlanItem],
|
||||
cursor_item: Option<usize>,
|
||||
draw_item: &mut dyn FnMut(&mut Frame, Rect, &PlanItem),
|
||||
) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let offset = scroll_offset(items, cursor_item, area.height);
|
||||
let mut top: u16 = 0;
|
||||
for item in items {
|
||||
let height = item.height();
|
||||
let item_top = top;
|
||||
top += height;
|
||||
if item_top < offset {
|
||||
continue;
|
||||
}
|
||||
let rel_y = item_top - offset;
|
||||
if rel_y >= area.height {
|
||||
break;
|
||||
}
|
||||
let rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + rel_y,
|
||||
width: area.width,
|
||||
height: height.min(area.height - rel_y),
|
||||
};
|
||||
match item {
|
||||
PlanItem::Header(label) => frame.render_widget(
|
||||
Paragraph::new(Line::styled(label.clone(), theme::header())),
|
||||
rect,
|
||||
),
|
||||
PlanItem::Gap => {}
|
||||
other => draw_item(frame, rect, other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Release view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
|
||||
let loadable = state.release_views.get(&id);
|
||||
let title = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail.title.clone(),
|
||||
_ => "Release".to_string(),
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" Global ▸ {title} "));
|
||||
|
||||
let detail = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail,
|
||||
Some(Loadable::Failed(error)) => {
|
||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||
}
|
||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
||||
};
|
||||
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
let [header_area, tracks_area] =
|
||||
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
|
||||
|
||||
let [art_area, _, info_area] = Layout::horizontal([
|
||||
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(header_area);
|
||||
draw_art(
|
||||
frame,
|
||||
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
|
||||
header_art(state, detail.cover_url.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![
|
||||
Line::default(),
|
||||
Line::styled(detail.title.clone(), theme::header()),
|
||||
Line::raw(artists.join(", ")),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!("{}{year} · {} tracks", detail.release_type, detail.tracks.len()),
|
||||
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.
|
||||
let visible = usize::from(tracks_area.height.max(1));
|
||||
let total = detail.tracks.len();
|
||||
let first = cursor
|
||||
.saturating_sub(visible / 2)
|
||||
.min(total.saturating_sub(visible));
|
||||
for (offset, track) in detail.tracks.iter().enumerate().skip(first).take(visible) {
|
||||
let rect = Rect {
|
||||
x: tracks_area.x,
|
||||
y: tracks_area.y + (offset - first) as u16,
|
||||
width: tracks_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let number = track
|
||||
.track_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| (offset + 1).to_string());
|
||||
super::track_row(frame, rect, state, track, number, cursor == offset);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search view (driven by the `:/query` command)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let search = &state.search;
|
||||
let mut title = format!(" Search: {} ", search.query);
|
||||
if search.loading {
|
||||
title.push_str("· searching… ");
|
||||
}
|
||||
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()));
|
||||
};
|
||||
if results.len() == 0 {
|
||||
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
|
||||
}
|
||||
|
||||
// All rows are one line tall: (line, right column, cursor index).
|
||||
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||
let mut index = 0;
|
||||
if !results.artists.is_empty() {
|
||||
rows.push((Line::styled("Artists", theme::header()), None, None));
|
||||
for artist in &results.artists {
|
||||
rows.push((
|
||||
Line::raw(artist.name.clone()),
|
||||
Some(artist_tile_meta(artist)),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
if !results.releases.is_empty() {
|
||||
rows.push((Line::styled("Releases", theme::header()), None, None));
|
||||
for release in &results.releases {
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::raw(release.title.clone()),
|
||||
Span::styled(format!(" {}", release.release_type), theme::dim()),
|
||||
]),
|
||||
Some(release_tile_meta(release)),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
if !results.tracks.is_empty() {
|
||||
rows.push((Line::styled("Tracks", theme::header()), None, None));
|
||||
for track in &results.tracks {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} 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,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(right),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
.position(|(_, _, c)| *c == Some(cursor))
|
||||
.unwrap_or(0);
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = cursor_row
|
||||
.saturating_sub(visible / 2)
|
||||
.min(rows.len().saturating_sub(visible));
|
||||
for (offset, (line, right, row_cursor)) in
|
||||
rows.into_iter().enumerate().skip(first).take(visible)
|
||||
{
|
||||
let rect = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (offset - first) as u16,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::Line;
|
||||
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) {
|
||||
let area = centered(frame.area(), 64, 16);
|
||||
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, _, paste, message, hint] = Layout::vertical([
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(3),
|
||||
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::raw("2. Sign-in completes here automatically."),
|
||||
Line::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(" (or right-click \"Open Furumi\" and copy its link)."),
|
||||
Line::raw("3. Paste it below and press Enter."),
|
||||
]
|
||||
};
|
||||
frame.render_widget(Paragraph::new(lines), steps);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(form.sso_url.clone(), theme::dim()))
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(Block::bordered().title("If the browser didn't open, visit").border_style(theme::dim())),
|
||||
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 · 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
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
pub mod art;
|
||||
mod global;
|
||||
mod login;
|
||||
mod playlists;
|
||||
pub mod theme;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
|
||||
|
||||
use crate::app::state::{AppState, Screen, Tab};
|
||||
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),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.areas(frame.area());
|
||||
|
||||
draw_tabs(frame, tabs_area, state);
|
||||
match state.active_tab {
|
||||
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::Devices => draw_main(frame, main_area, state),
|
||||
}
|
||||
draw_status(frame, status_area, state);
|
||||
|
||||
if state.help_visible {
|
||||
draw_help(frame, keymap);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let titles = Tab::ALL
|
||||
.iter()
|
||||
.map(|tab| format!(" {} {} ", tab.index() + 1, tab.title()));
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(state.active_tab.index())
|
||||
.style(theme::dim())
|
||||
.highlight_style(theme::tab_active())
|
||||
.divider("");
|
||||
frame.render_widget(tabs, area);
|
||||
}
|
||||
|
||||
fn draw_main(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {} ", state.active_tab.title()))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let (summary, milestone) = match state.active_tab {
|
||||
Tab::Devices => ("Connected devices and playback transfer", "milestone 5"),
|
||||
_ => ("", ""),
|
||||
};
|
||||
let lines = vec![
|
||||
Line::default(),
|
||||
Line::styled(summary, theme::accent()),
|
||||
Line::styled(format!("coming in {milestone}"), theme::dim()),
|
||||
Line::default(),
|
||||
Line::styled("Tab / Shift-Tab or 1-5 to switch tabs", theme::dim()),
|
||||
Line::styled("? keybindings q quit", theme::dim()),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
|
||||
frame.render_widget(paragraph, centered_vertically(inner, 6));
|
||||
}
|
||||
|
||||
/// One track row used by every track list: ♥ marker for liked tracks, the
|
||||
/// title and artists on the left, tech info and duration on the right.
|
||||
pub(crate) fn track_row(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::api::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
) {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{index_label:>3} "), theme::dim()),
|
||||
heart,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(format!(" {}", track.artist_line()), theme::dim()),
|
||||
]);
|
||||
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())
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
||||
area,
|
||||
);
|
||||
if selected {
|
||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only queue listing; the playing track is highlighted.
|
||||
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let player = &state.player;
|
||||
let block = Block::bordered()
|
||||
.title(format!(" Queue — {} tracks ", player.queue.len()))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if player.queue.is_empty() {
|
||||
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",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
middle,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = player
|
||||
.queue_pos
|
||||
.saturating_sub(visible / 2)
|
||||
.min(player.queue.len().saturating_sub(visible));
|
||||
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
|
||||
let row = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (index - first) as u16,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
track_row(
|
||||
frame,
|
||||
row,
|
||||
state,
|
||||
track,
|
||||
(index + 1).to_string(),
|
||||
index == player.queue_pos,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_secs(secs: f64) -> String {
|
||||
let total = secs.max(0.0).round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// Playback time, progress bar, queue position, volume and mode flags.
|
||||
/// 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 {
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
80..=109 => 14,
|
||||
_ => 22,
|
||||
};
|
||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||
let filled = (ratio * bar_width as f64).round() as usize;
|
||||
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
} else {
|
||||
spans.push(Span::styled("/ ", theme::dim()));
|
||||
}
|
||||
spans.push(Span::raw(track.duration_label()));
|
||||
if !player.queue.is_empty() && width >= 70 {
|
||||
spans.push(Span::styled(
|
||||
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if width >= 80 {
|
||||
let volume_cells = usize::from(player.volume / 10);
|
||||
spans.extend([
|
||||
Span::styled(" vol ", theme::dim()),
|
||||
Span::styled("█".repeat(volume_cells), theme::accent()),
|
||||
Span::styled("░".repeat(10 - volume_cells), theme::dim()),
|
||||
Span::raw(format!(" {:3}%", player.volume)),
|
||||
Span::styled(" shuffle ", theme::dim()),
|
||||
Span::raw(if player.shuffle { "on" } else { "off" }.to_string()),
|
||||
Span::styled(" repeat ", theme::dim()),
|
||||
Span::raw(player.repeat.label().to_string()),
|
||||
]);
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
format!(" {}%", player.volume),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
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
|
||||
// truncates into whatever is left.
|
||||
let center = player_right_line(player, area.width);
|
||||
let center_width = (center.width() as u16).min(area.width);
|
||||
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, user_area] = Layout::horizontal([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(center_width),
|
||||
Constraint::Length(user_width),
|
||||
])
|
||||
.areas(player_row);
|
||||
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 {
|
||||
Some(track) if player.playing => {
|
||||
if player.paused {
|
||||
spans.push(Span::styled("⏸ ", theme::dim()));
|
||||
} else {
|
||||
spans.push(Span::styled("▶ ", theme::accent()));
|
||||
}
|
||||
if state.likes.contains(&track.id) {
|
||||
spans.push(Span::styled("♥ ", theme::accent()));
|
||||
}
|
||||
spans.push(Span::raw(track.title.clone()));
|
||||
spans.push(Span::styled(
|
||||
format!(" — {}", track.artist_line()),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
spans.push(Span::styled("■ stopped", theme::dim()));
|
||||
}
|
||||
}
|
||||
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![
|
||||
Span::styled(":", theme::header()),
|
||||
Span::raw(state.cmdline.input.clone()),
|
||||
Span::styled("█", theme::accent()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), message_row);
|
||||
return;
|
||||
}
|
||||
|
||||
let message = match &state.status_message {
|
||||
Some(message) => Line::styled(message.clone(), theme::accent()),
|
||||
None => match &state.player.current {
|
||||
// Idle line doubles as the current track's tech data display.
|
||||
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
|
||||
Line::styled(track.tech_label_full(), theme::dim())
|
||||
}
|
||||
_ => Line::styled("press ? for keybindings", theme::dim()),
|
||||
},
|
||||
};
|
||||
frame.render_widget(Paragraph::new(message), message_row);
|
||||
|
||||
if let Some(pending) = &state.pending_keys {
|
||||
let pending = Paragraph::new(Line::styled(format!("{pending} …"), theme::header()))
|
||||
.alignment(Alignment::Right);
|
||||
frame.render_widget(pending, message_row);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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()),
|
||||
);
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
|
||||
let [rect] = Layout::horizontal([Constraint::Length(width)])
|
||||
.flex(ratatui::layout::Flex::Center)
|
||||
.areas(area);
|
||||
let [rect] = Layout::vertical([Constraint::Length(height)])
|
||||
.flex(ratatui::layout::Flex::Center)
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
|
||||
fn centered_vertically(area: Rect, content_height: u16) -> Rect {
|
||||
let [rect] = Layout::vertical([Constraint::Length(content_height)])
|
||||
.flex(ratatui::layout::Flex::Center)
|
||||
.areas(area);
|
||||
rect
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::{theme, track_row};
|
||||
use crate::app::state::{AppState, Loadable};
|
||||
use crate::app::update::playlist_tracks;
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
match state.playlists.opened {
|
||||
Some(opened) => draw_opened(frame, area, state, opened.id, opened.cursor),
|
||||
None => draw_list(frame, area, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
inner
|
||||
}
|
||||
|
||||
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 };
|
||||
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
|
||||
}
|
||||
|
||||
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let inner = bordered(frame, area, " Playlists ".to_string());
|
||||
let selected = state.playlists.selected;
|
||||
|
||||
let list = match &state.playlists.list {
|
||||
Some(Loadable::Ready(list)) => list,
|
||||
Some(Loadable::Failed(error)) => {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled(error.clone(), Style::new().fg(Color::Red)),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return centered_line(frame, inner, Line::styled("loading playlists…", theme::dim()));
|
||||
}
|
||||
};
|
||||
if list.is_empty() {
|
||||
return centered_line(frame, inner, Line::styled("no playlists yet", theme::dim()));
|
||||
}
|
||||
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = selected
|
||||
.saturating_sub(visible / 2)
|
||||
.min(list.len().saturating_sub(visible));
|
||||
for (index, playlist) in list.iter().enumerate().skip(first).take(visible) {
|
||||
let row = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (index - first) as u16,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let marker = if playlist.kind == "likes" {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} 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()),
|
||||
])),
|
||||
row,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
format!("{} trk", playlist.track_count),
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Right),
|
||||
row,
|
||||
);
|
||||
if index == selected {
|
||||
frame.buffer_mut().set_style(row, theme::tab_active());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
|
||||
let loadable = state.playlist_views.get(&id);
|
||||
let title = match loadable {
|
||||
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
|
||||
_ => " Playlists ▸ … ".to_string(),
|
||||
};
|
||||
let inner = bordered(frame, area, title);
|
||||
|
||||
if let Some(Loadable::Failed(error)) = loadable {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled(error.clone(), Style::new().fg(Color::Red)),
|
||||
);
|
||||
}
|
||||
let Some(tracks) = playlist_tracks(state, id) else {
|
||||
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()));
|
||||
}
|
||||
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = cursor
|
||||
.saturating_sub(visible / 2)
|
||||
.min(tracks.len().saturating_sub(visible));
|
||||
for (index, track) in tracks.iter().enumerate().skip(first).take(visible) {
|
||||
let row = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (index - first) as u16,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
track_row(frame, row, state, track, (index + 1).to_string(), index == cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
pub const ACCENT: Color = Color::Cyan;
|
||||
pub const DIM: Color = Color::DarkGray;
|
||||
|
||||
pub fn accent() -> Style {
|
||||
Style::new().fg(ACCENT)
|
||||
}
|
||||
|
||||
pub fn dim() -> Style {
|
||||
Style::new().fg(DIM)
|
||||
}
|
||||
|
||||
pub fn tab_active() -> Style {
|
||||
Style::new()
|
||||
.fg(Color::Black)
|
||||
.bg(ACCENT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn header() -> Style {
|
||||
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
Reference in New Issue
Block a user