Added fed artist page

This commit is contained in:
Ultradesu
2026-07-17 01:17:54 +03:00
parent 73ee4dab9d
commit a723fcc18e
14 changed files with 1049 additions and 140 deletions
+137 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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())