Added fed artist page
This commit is contained in:
+137
-4
@@ -23,6 +23,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
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),
|
||||
Some(GlobalView::FedArtist { cursor }) => draw_fed_artist(frame, area, state, *cursor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,7 +716,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let empty_results = SearchResults::default();
|
||||
let results = match &search.results {
|
||||
Some(results) => results,
|
||||
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
|
||||
None if !state.search.fed_tracks.is_empty()
|
||||
|| !state.search.fed_artists.is_empty()
|
||||
|| state.search.fed_loading =>
|
||||
{
|
||||
&empty_results
|
||||
}
|
||||
None => {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
@@ -725,7 +731,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
}
|
||||
};
|
||||
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
|
||||
if results.len() == 0
|
||||
&& state.search.fed_tracks.is_empty()
|
||||
&& state.search.fed_artists.is_empty()
|
||||
&& !state.search.fed_loading
|
||||
{
|
||||
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
|
||||
}
|
||||
|
||||
@@ -782,8 +792,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// Tracks found on the federated network, marked with the owning peer.
|
||||
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
|
||||
// Federated section: artists whose card can be assembled, then tracks
|
||||
// (both marked with the owning peers).
|
||||
if !state.search.fed_tracks.is_empty()
|
||||
|| !state.search.fed_artists.is_empty()
|
||||
|| state.search.fed_loading
|
||||
{
|
||||
if !rows.is_empty() {
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
@@ -793,6 +807,22 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
"Federation"
|
||||
};
|
||||
rows.push((Line::styled(header, theme::header()), None, None));
|
||||
for hit in &state.search.fed_artists {
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(hit.name.clone()),
|
||||
Span::styled(" артист · открыть карточку", theme::dim()),
|
||||
]),
|
||||
Some(format!(
|
||||
"{} peer{}",
|
||||
hit.peers,
|
||||
if hit.peers == 1 { "" } else { "s" }
|
||||
)),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
for fed in &state.search.fed_tracks {
|
||||
let origin = if fed.own {
|
||||
"your library".to_string()
|
||||
@@ -842,3 +872,106 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federated artist card (assembled from peer catalogs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let Some((name, data)) = &state.fed_artist_view else {
|
||||
return centered_line(frame, area, Line::styled("no card is open", theme::dim()));
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" {name} — federation "));
|
||||
let card = match data {
|
||||
Loadable::Loading => {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled("собираем карточку с пиров…", theme::dim()),
|
||||
);
|
||||
}
|
||||
Loadable::Failed(message) => {
|
||||
return centered_line(frame, inner, Line::styled(message.clone(), error_style()));
|
||||
}
|
||||
Loadable::Ready(card) => card,
|
||||
};
|
||||
|
||||
// All rows are one line tall: (line, right column, cursor index).
|
||||
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||
rows.push((
|
||||
Line::styled(
|
||||
format!(
|
||||
"{} релизов · {} треков · с {} пиров",
|
||||
card.releases.len(),
|
||||
card.releases.iter().map(|r| r.tracks.len()).sum::<usize>(),
|
||||
card.peers
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let mut index = 0;
|
||||
for release in &card.releases {
|
||||
rows.push((Line::default(), None, None));
|
||||
let mut header = release.title.clone();
|
||||
if let Some(year) = release.year {
|
||||
header.push_str(&format!(" ({year})"));
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled(header, theme::header()),
|
||||
Span::styled(format!(" {}", release.release_type), theme::dim()),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
for track in &release.tracks {
|
||||
let number = track
|
||||
.track_number
|
||||
.map(|n| format!("{n:>2}. "))
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let duration = track
|
||||
.duration_seconds
|
||||
.map(|d| {
|
||||
let total = d.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let sources = if track.sources.len() > 1 {
|
||||
format!("{} · {} пиров", duration, track.sources.len())
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(format!("{number}{}", track.title)),
|
||||
]),
|
||||
Some(sources),
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+38
-6
@@ -12,6 +12,7 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::input::LineEdit;
|
||||
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
|
||||
@@ -298,12 +299,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
||||
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);
|
||||
let mut spans = vec![Span::styled(":", theme::header())];
|
||||
spans.extend(line_edit_spans(
|
||||
&state.cmdline.input,
|
||||
usize::from(message_row.width.saturating_sub(2)),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), message_row);
|
||||
draw_version(frame, message_row);
|
||||
return;
|
||||
}
|
||||
@@ -448,3 +449,34 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
|
||||
/// Renders a [`LineEdit`] as spans with a visible cursor, windowed so the
|
||||
/// cursor always stays on screen when the value is wider than `width`.
|
||||
pub(crate) fn line_edit_spans(edit: &LineEdit, width: usize) -> Vec<Span<'static>> {
|
||||
let width = width.max(2);
|
||||
let chars: Vec<char> = edit.as_str().chars().collect();
|
||||
let cursor = edit.cursor().min(chars.len());
|
||||
// Window start: keep the cursor within the visible slice (one cell is
|
||||
// reserved for the cursor block itself when it sits at the end).
|
||||
let start = (cursor + 1).saturating_sub(width);
|
||||
let end = (start + width.saturating_sub(1)).min(chars.len());
|
||||
let before: String = chars[start..cursor].iter().collect();
|
||||
let (under, after): (String, String) = if cursor < chars.len() {
|
||||
(
|
||||
chars[cursor].to_string(),
|
||||
chars[cursor + 1..end.max(cursor + 1)].iter().collect(),
|
||||
)
|
||||
} else {
|
||||
("█".to_string(), String::new())
|
||||
};
|
||||
let cursor_style = if cursor < chars.len() {
|
||||
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
theme::accent()
|
||||
};
|
||||
vec![
|
||||
Span::raw(before),
|
||||
Span::styled(under, cursor_style),
|
||||
Span::raw(after),
|
||||
]
|
||||
}
|
||||
|
||||
+16
-31
@@ -34,7 +34,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
||||
let area = centered(frame.area(), 64, 5);
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
@@ -45,20 +45,8 @@ fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
frame.render_widget(block, area);
|
||||
let [entry_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||
// Keep the tail visible when the value (a ticket) exceeds the width.
|
||||
let visible: String = {
|
||||
let width = usize::from(entry_area.width.saturating_sub(2));
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let skip = chars.len().saturating_sub(width);
|
||||
chars[skip..].iter().collect()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(visible),
|
||||
Span::styled("█", theme::accent()),
|
||||
])),
|
||||
entry_area,
|
||||
);
|
||||
let spans = super::line_edit_spans(input, usize::from(entry_area.width.saturating_sub(1)));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), entry_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
@@ -118,16 +106,18 @@ fn draw_edit(
|
||||
});
|
||||
let field_inner = field_block.inner(areas[index]);
|
||||
frame.render_widget(field_block, areas[index]);
|
||||
let width = usize::from(field_inner.width.saturating_sub(1));
|
||||
let mut shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
let width = usize::from(field_inner.width);
|
||||
if focused {
|
||||
shown.push('█');
|
||||
let spans = super::line_edit_spans(&field.value, width);
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), field_inner);
|
||||
} else {
|
||||
let shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
|
||||
let footer = areas[areas.len() - 1];
|
||||
@@ -394,7 +384,7 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) {
|
||||
fn draw_name_entry(frame: &mut Frame, input: &crate::app::input::LineEdit, busy: bool) {
|
||||
let area = centered(frame.area(), 44, 7);
|
||||
let block = Block::bordered()
|
||||
.title(" New playlist ")
|
||||
@@ -416,13 +406,8 @@ fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) {
|
||||
.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 spans = super::line_edit_spans(input, usize::from(name_inner.width));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), name_inner);
|
||||
|
||||
let hint = if busy {
|
||||
Line::styled("creating…", theme::accent())
|
||||
|
||||
Reference in New Issue
Block a user