From a723fcc18ea230d5b99c544060c157c6fe2dbb40 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 17 Jul 2026 01:17:54 +0300 Subject: [PATCH] Added fed artist page --- src/app/cmdline.rs | 26 +-- src/app/event.rs | 10 +- src/app/input.rs | 147 +++++++++++++++ src/app/mod.rs | 29 ++- src/app/popup.rs | 50 ++---- src/app/state.rs | 21 ++- src/app/update.rs | 149 ++++++++++++--- src/federation/audio.rs | 2 +- src/federation/catalog.rs | 370 ++++++++++++++++++++++++++++++++++++++ src/federation/mod.rs | 141 ++++++++++++++- src/library/mod.rs | 12 ++ src/ui/global.rs | 141 ++++++++++++++- src/ui/mod.rs | 44 ++++- src/ui/popup.rs | 47 ++--- 14 files changed, 1049 insertions(+), 140 deletions(-) create mode 100644 src/app/input.rs create mode 100644 src/federation/catalog.rs diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index ab2e9f0..221254f 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use crate::app::Runtime; use crate::app::command::{self, Command, Parsed}; @@ -17,32 +17,22 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { match key.code { KeyCode::Esc => cancel(state), KeyCode::Enter => commit(state, runtime), - KeyCode::Backspace => { - if state.cmdline.input.pop().is_none() { - // Backspace on an empty line closes it, like vim. - cancel(state); - return; + // Backspace on an empty line closes it, like vim. + KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state), + _ => { + if state.cmdline.input.handle_key(key) { + after_change(state, runtime); } - after_change(state, runtime); } - KeyCode::Char(c) if is_typing(key) => { - state.cmdline.input.push(c); - after_change(state, runtime); - } - _ => {} } } pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) { let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); - state.cmdline.input.push_str(&cleaned); + state.cmdline.input.insert_str(&cleaned); after_change(state, runtime); } -fn is_typing(key: KeyEvent) -> bool { - key.modifiers.difference(KeyModifiers::SHIFT).is_empty() -} - /// Re-evaluate the input after every edit; live commands (search) take /// effect immediately, while typing. fn after_change(state: &mut AppState, runtime: &Runtime) { @@ -101,6 +91,7 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) { state.search.loading = false; state.search.results = None; state.search.fed_tracks.clear(); + state.search.fed_artists.clear(); state.search.fed_loading = false; return; } @@ -123,6 +114,7 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) { // The same query also runs against the federated network (when the // node is up); its results render as a separate, marked section. state.search.fed_tracks.clear(); + state.search.fed_artists.clear(); state.search.fed_loading = false; if runtime.federation.settings().enabled { state.search.fed_loading = true; diff --git a/src/app/event.rs b/src/app/event.rs index 5070c8d..bad1cca 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -89,10 +89,16 @@ pub enum AppEvent { }, /// A status snapshot for the Federation tab. FederationStatus(crate::federation::FedStatus), - /// Tracks found on the federated network for the live search. + /// Federated live-search results (artists a card can be opened for, + /// plus matching tracks). FedSearchLoaded { seq: u64, - result: Result, String>, + result: Result, + }, + /// A federated artist card finished assembling. + FedArtistLoaded { + name: String, + result: Result, }, /// A federated track finished downloading and is ready to play. FedPlayReady { diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..8cbe116 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,147 @@ +//! One-line text editing with a movable cursor, shared by every text input +//! (command line, edit forms, popup fields). + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// A single-line value plus a cursor position (in characters). Dereferences +/// to `&str`, so read paths treat it like the plain string it wraps. +#[derive(Debug, Clone, Default)] +pub struct LineEdit { + value: String, + cursor: usize, +} + +impl LineEdit { + /// Starts with `value` and the cursor at its end. + pub fn new(value: impl Into) -> Self { + let value = value.into(); + let cursor = value.chars().count(); + Self { value, cursor } + } + + pub fn as_str(&self) -> &str { + &self.value + } + + /// Cursor position in characters (0..=len). + pub fn cursor(&self) -> usize { + self.cursor + } + + fn byte_index(&self, chars: usize) -> usize { + self.value + .char_indices() + .nth(chars) + .map(|(index, _)| index) + .unwrap_or(self.value.len()) + } + + pub fn clear(&mut self) { + self.value.clear(); + self.cursor = 0; + } + + pub fn insert(&mut self, c: char) { + let at = self.byte_index(self.cursor); + self.value.insert(at, c); + self.cursor += 1; + } + + pub fn insert_str(&mut self, s: &str) { + let at = self.byte_index(self.cursor); + self.value.insert_str(at, s); + self.cursor += s.chars().count(); + } + + /// Removes the character before the cursor; `false` when at the start. + pub fn backspace(&mut self) -> bool { + if self.cursor == 0 { + return false; + } + let at = self.byte_index(self.cursor - 1); + self.value.remove(at); + self.cursor -= 1; + true + } + + /// Removes the character under the cursor. + pub fn delete(&mut self) { + if self.cursor < self.value.chars().count() { + let at = self.byte_index(self.cursor); + self.value.remove(at); + } + } + + /// Applies one editing key (characters, backspace/delete, cursor + /// movement). Returns `false` for keys this editor does not handle + /// (Enter, Esc, Tab, ...), which the caller interprets itself. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + self.insert(c); + } + KeyCode::Backspace => { + self.backspace(); + } + KeyCode::Delete => self.delete(), + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.value.chars().count()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.value.chars().count(), + _ => return false, + } + true + } +} + +impl std::ops::Deref for LineEdit { + type Target = str; + + fn deref(&self) -> &str { + &self.value + } +} + +impl std::fmt::Display for LineEdit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn edits_at_cursor() { + let mut edit = LineEdit::new("hllo"); + assert_eq!(edit.cursor(), 4); + edit.handle_key(key(KeyCode::Home)); + edit.handle_key(key(KeyCode::Right)); + edit.handle_key(key(KeyCode::Char('e'))); + assert_eq!(edit.as_str(), "hello"); + assert_eq!(edit.cursor(), 2); + edit.handle_key(key(KeyCode::End)); + edit.handle_key(key(KeyCode::Backspace)); + assert_eq!(edit.as_str(), "hell"); + edit.handle_key(key(KeyCode::Home)); + edit.handle_key(key(KeyCode::Delete)); + assert_eq!(edit.as_str(), "ell"); + } + + #[test] + fn multibyte_safe() { + let mut edit = LineEdit::new("метл"); + edit.handle_key(key(KeyCode::Left)); + edit.insert('а'); + assert_eq!(edit.as_str(), "метал"); + edit.handle_key(key(KeyCode::End)); + edit.insert('л'); + assert_eq!(edit.as_str(), "металл"); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index d5bcb84..6cc9b18 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ pub mod action; mod cmdline; +pub mod input; pub mod command; pub mod event; mod popup; @@ -244,7 +245,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { }); } } - state::GlobalView::Search { .. } => {} + state::GlobalView::Search { .. } | state::GlobalView::FedArtist { .. } => {} } } @@ -425,6 +426,17 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { let _ = tx.send(AppEvent::FedTicket(result)); }); } + Effect::FedOpenArtist(name) => { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = fed + .artist_card(&name) + .await + .map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::FedArtistLoaded { name, result }); + }); + } Effect::FedPlay(fed_track) => { let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); @@ -900,7 +912,10 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } state.search.fed_loading = false; match result { - Ok(tracks) => state.search.fed_tracks = tracks, + Ok(results) => { + state.search.fed_artists = results.artists; + state.search.fed_tracks = results.tracks; + } Err(message) => tracing::warn!(%message, "federated search failed"), } } @@ -920,6 +935,16 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } Err(message) => state.status_message = Some(format!("federation: {message}")), }, + AppEvent::FedArtistLoaded { name, result } => { + if let Some((current, data)) = &mut state.fed_artist_view + && *current == name + { + *data = match result { + Ok(card) => state::Loadable::Ready(card), + Err(message) => state::Loadable::Failed(message), + }; + } + } AppEvent::FedTicket(result) => match result { Ok(ticket) => { state.popup = Some(state::Popup::FedText { diff --git a/src/app/popup.rs b/src/app/popup.rs index bee4da9..d8a3db0 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -5,7 +5,7 @@ use std::sync::Arc; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use crate::app::Runtime; use crate::app::event::AppEvent; @@ -59,7 +59,7 @@ fn handle_fed_input( state: &mut AppState, runtime: &Runtime, field: FedInputField, - mut input: String, + mut input: crate::app::input::LineEdit, key: KeyEvent, ) { match key.code { @@ -85,15 +85,10 @@ fn handle_fed_input( } } } - KeyCode::Backspace => { - input.pop(); + _ => { + input.handle_key(key); state.popup = Some(Popup::FedInput { field, input }); } - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - input.push(c); - state.popup = Some(Popup::FedInput { field, input }); - } - _ => state.popup = Some(Popup::FedInput { field, input }), } } @@ -101,11 +96,11 @@ fn handle_fed_input( pub fn handle_paste(state: &mut AppState, pasted: &str) { let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); match &mut state.popup { - Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned), - Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned), + Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.insert_str(&cleaned), + Some(Popup::FedInput { input, .. }) => input.insert_str(&cleaned), Some(Popup::Edit { fields, focus, .. }) => { if let Some(field) = fields.get_mut(*focus) { - field.value.push_str(&cleaned); + field.value.insert_str(&cleaned); } } _ => {} @@ -152,17 +147,11 @@ fn handle_edit( let len = fields.len().max(1); focus = (focus + len - 1) % len; } - KeyCode::Backspace => { + _ => { if let Some(field) = fields.get_mut(focus) { - field.value.pop(); + field.value.handle_key(key); } } - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - if let Some(field) = fields.get_mut(focus) { - field.value.push(c); - } - } - _ => {} } state.popup = Some(Popup::Edit { target, @@ -393,7 +382,7 @@ fn handle_picker( if cursor == 0 { state.popup = Some(Popup::NewPlaylist { for_track: Some(track), - input: String::new(), + input: crate::app::input::LineEdit::default(), busy: false, }); } else if let Some((id, title)) = options.get(cursor - 1).cloned() { @@ -408,7 +397,7 @@ fn handle_name_entry( state: &mut AppState, runtime: &Runtime, for_track: Option, - mut input: String, + mut input: crate::app::input::LineEdit, busy: bool, key: KeyEvent, ) { @@ -445,23 +434,8 @@ fn handle_name_entry( busy: true, }); } - KeyCode::Backspace => { - input.pop(); - state.popup = Some(Popup::NewPlaylist { - for_track, - input, - busy: false, - }); - } - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - input.push(c); - state.popup = Some(Popup::NewPlaylist { - for_track, - input, - busy: false, - }); - } _ => { + input.handle_key(key); state.popup = Some(Popup::NewPlaylist { for_track, input, diff --git a/src/app/state.rs b/src/app/state.rs index ed4a093..af2f6db 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use crate::app::input::LineEdit; use crate::art::ArtImage; use crate::config::keymap::KeyContext; use crate::library::models::{ @@ -69,6 +70,10 @@ pub enum GlobalView { Search { cursor: usize, }, + /// A federated artist card (data lives in `AppState::fed_artist_view`). + FedArtist { + cursor: usize, + }, } /// The Global tab: the whole server library of artists. @@ -308,14 +313,14 @@ pub enum DeleteTarget { #[derive(Debug, Clone)] pub struct EditField { pub label: &'static str, - pub value: String, + pub value: LineEdit, } impl EditField { pub fn new(label: &'static str, value: impl Into) -> Self { Self { label, - value: value.into(), + value: LineEdit::new(value), } } } @@ -330,7 +335,7 @@ pub enum Popup { /// added to it right after creation. NewPlaylist { for_track: Option, - input: String, + input: LineEdit, busy: bool, }, /// Metadata edit form for a track, release, artist or playlist. @@ -354,7 +359,7 @@ pub enum Popup { /// One-line text entry on the Federation tab (network id, peer ticket). FedInput { field: FedInputField, - input: String, + input: LineEdit, }, /// Wrapped read-only text (this peer's connection ticket). FedText { title: String, text: String }, @@ -422,7 +427,7 @@ pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> { #[derive(Debug, Default)] pub struct Cmdline { pub active: bool, - pub input: String, + pub input: LineEdit, /// A live command (search) applied effects during this session; Esc /// undoes them, Enter keeps them. pub live: bool, @@ -437,6 +442,9 @@ pub struct SearchState { /// Tracks found on the federated network (empty while federation is /// off); rendered as a separate, marked section. pub fed_tracks: Vec, + /// Artists a federated card can be opened for — from artist records and + /// from the artist names of matching tracks. + pub fed_artists: Vec, pub fed_loading: bool, } @@ -588,6 +596,9 @@ pub struct AppState { pub logs: LogsTab, pub queue_tab: QueueTab, pub federation: FederationTab, + /// The one federated artist card being viewed (name + loading state); + /// opening another card replaces it. + pub fed_artist_view: Option<(String, Loadable)>, pub track_selection: TrackSelection, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. diff --git a/src/app/update.rs b/src/app/update.rs index 55f524e..4f6bb21 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -49,6 +49,8 @@ pub enum Effect { FedShowTicket, /// Download (or resolve) a federated track and play it. FedPlay(crate::federation::FedTrack), + /// Assemble the federated artist card (fan-out to the owning peers). + FedOpenArtist(String), } pub fn update(state: &mut AppState, action: Action) -> Option { @@ -199,7 +201,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { // The command line opens pre-filled with "/": typing continues // the live search, exactly as if `:` then `/` were pressed. state.cmdline.active = true; - state.cmdline.input = "/".to_string(); + state.cmdline.input = crate::app::input::LineEdit::new("/"); state.cmdline.live = true; state.search = SearchState::default(); state.active_tab = Tab::Global; @@ -288,7 +290,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { Action::NewPlaylist => { state.popup = Some(super::state::Popup::NewPlaylist { for_track: None, - input: String::new(), + input: crate::app::input::LineEdit::default(), busy: false, }); None @@ -548,7 +550,7 @@ fn selected_release_card(state: &AppState) -> Option None, + GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None, } } @@ -880,6 +882,7 @@ pub fn selected_track(state: &AppState) -> Option { let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?; results.tracks.get(offset).cloned() } + GlobalView::FedArtist { .. } => None, }, Tab::Playlists => { let opened = state.playlists.opened.as_ref()?; @@ -919,7 +922,7 @@ fn selected_release_id(state: &AppState) -> Option { let offset = cursor.checked_sub(results.artists.len())?; results.releases.get(offset).map(|r| r.id) } - GlobalView::Release { .. } => None, + GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None, } } @@ -1183,7 +1186,9 @@ fn page_step(state: &AppState) -> isize { lines } } - Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) => lines, + Some(GlobalView::Release { .. }) + | Some(GlobalView::Search { .. }) + | Some(GlobalView::FedArtist { .. }) => lines, } } @@ -1343,6 +1348,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { Some(GlobalView::Search { cursor }) => { // Local results plus the federated section below them. let total = (state.search.results.as_ref().map_or(0, |r| r.len()) + + state.search.fed_artists.len() + state.search.fed_tracks.len()) as isize; if total == 0 { return; @@ -1351,6 +1357,25 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { set_view_cursor(state, next as usize); state.track_selection.clear(); } + Some(GlobalView::FedArtist { cursor }) => { + let total = fed_card_len(state) as isize; + if total == 0 { + return; + } + let next = (cursor as isize + dy).clamp(0, total - 1); + set_view_cursor(state, next as usize); + state.track_selection.clear(); + } + } +} + +/// Selectable rows of the open federated artist card (its tracks). +pub(crate) fn fed_card_len(state: &AppState) -> usize { + match &state.fed_artist_view { + Some((_, Loadable::Ready(card))) => { + card.releases.iter().map(|r| r.tracks.len()).sum() + } + _ => 0, } } @@ -1359,7 +1384,8 @@ fn set_view_cursor(state: &mut AppState, value: usize) { match view { GlobalView::Artist { cursor, .. } | GlobalView::Release { cursor, .. } - | GlobalView::Search { cursor } => *cursor = value, + | GlobalView::Search { cursor } + | GlobalView::FedArtist { cursor } => *cursor = value, } } } @@ -1398,8 +1424,11 @@ fn current_view_len(state: &AppState) -> usize { _ => 0, }, Some(GlobalView::Search { .. }) => { - state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len() + state.search.results.as_ref().map_or(0, |r| r.len()) + + state.search.fed_artists.len() + + state.search.fed_tracks.len() } + Some(GlobalView::FedArtist { .. }) => fed_card_len(state), } } @@ -1527,15 +1556,6 @@ fn select_current(state: &mut AppState) -> Option { not_yet(state, "Navigation in this view"); return None; } - enum Outcome { - Push(GlobalView), - Play { - tracks: Vec, - start: usize, - }, - PlayFed(crate::federation::FedTrack), - Nothing, - } let outcome = match state.global.stack.last().copied() { None => match state.global.artists.get(state.global.selected) { Some(artist) => Outcome::Push(GlobalView::Artist { @@ -1604,18 +1624,32 @@ fn select_current(state: &mut AppState) -> Option { start: cursor - artists - releases, } } else { - let fed_index = cursor - artists - releases - results.tracks.len(); - match state.search.fed_tracks.get(fed_index) { - Some(fed) => Outcome::PlayFed(fed.clone()), + fed_outcome(state, cursor - artists - releases - results.tracks.len()) + } + } + None => fed_outcome(state, cursor), + }, + Some(GlobalView::FedArtist { cursor }) => { + match &state.fed_artist_view { + Some((name, Loadable::Ready(card))) => { + match card + .releases + .iter() + .flat_map(|release| release.tracks.iter().map(move |t| (release, t))) + .nth(cursor) + { + Some((release, track)) => { + match fed_track_from_card(name, release, track) { + Some(fed) => Outcome::PlayFed(fed), + None => Outcome::Nothing, + } + } None => Outcome::Nothing, } } + _ => Outcome::Nothing, } - None => match state.search.fed_tracks.get(cursor) { - Some(fed) => Outcome::PlayFed(fed.clone()), - None => Outcome::Nothing, - }, - }, + } }; match outcome { Outcome::Push(view) => { @@ -1632,10 +1666,60 @@ fn select_current(state: &mut AppState) -> Option { state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title)); Some(Effect::FedPlay(fed)) } + Outcome::OpenFedArtist(name) => { + state.fed_artist_view = Some((name.clone(), Loadable::Loading)); + state.global.stack.push(GlobalView::FedArtist { cursor: 0 }); + state.active_tab = Tab::Global; + Some(Effect::FedOpenArtist(name)) + } Outcome::Nothing => None, } } +/// What Enter resolved to in the current view. +enum Outcome { + Push(GlobalView), + Play { + tracks: Vec, + start: usize, + }, + PlayFed(crate::federation::FedTrack), + OpenFedArtist(String), + Nothing, +} + +/// Enter inside the federated section of the search results: artists open +/// their card, tracks play. +fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome { + let artists = &state.search.fed_artists; + if fed_index < artists.len() { + return Outcome::OpenFedArtist(artists[fed_index].name.clone()); + } + match state.search.fed_tracks.get(fed_index - artists.len()) { + Some(fed) => Outcome::PlayFed(fed.clone()), + None => Outcome::Nothing, + } +} + +/// A playable FedTrack out of a card row (first source; the rest are +/// fallbacks for a later improvement). +fn fed_track_from_card( + artist: &str, + release: &crate::federation::FedRelease, + track: &crate::federation::FedCardTrack, +) -> Option { + let (owner, item_id) = track.sources.first()?.clone(); + Some(crate::federation::FedTrack { + item_id, + owner, + own: false, + title: track.title.clone(), + artist_names: vec![artist.to_string()], + year: release.year, + duration_seconds: track.duration_seconds.map(|d| d.round() as i64), + }) +} + /// Enter on the Federation tab: toggle switches, open text inputs, run /// one-shot operations. The heavy lifting happens in perform_effect(). fn federation_select(state: &mut AppState) -> Option { @@ -1646,7 +1730,7 @@ fn federation_select(state: &mut AppState) -> Option { if !settings.enabled && settings.network_id.trim().is_empty() { state.popup = Some(Popup::FedInput { field: FedInputField::NetworkId, - input: String::new(), + input: crate::app::input::LineEdit::default(), }); return None; } @@ -1656,7 +1740,9 @@ fn federation_select(state: &mut AppState) -> Option { FedRow::NetworkId => { state.popup = Some(Popup::FedInput { field: FedInputField::NetworkId, - input: state.federation.settings.network_id.clone(), + input: crate::app::input::LineEdit::new( + state.federation.settings.network_id.clone(), + ), }); None } @@ -1669,7 +1755,7 @@ fn federation_select(state: &mut AppState) -> Option { FedRow::Connect => { state.popup = Some(Popup::FedInput { field: FedInputField::ConnectTicket, - input: String::new(), + input: crate::app::input::LineEdit::default(), }); None } @@ -1724,10 +1810,14 @@ fn go_back(state: &mut AppState) { state.active_tab = origin; return; } - if let Some(popped) = state.global.stack.pop() - && matches!(popped, GlobalView::Search { .. }) { + if let Some(popped) = state.global.stack.pop() { + if matches!(popped, GlobalView::Search { .. }) { state.search = SearchState::default(); } + if matches!(popped, GlobalView::FedArtist { .. }) { + state.fed_artist_view = None; + } + } } _ => {} } @@ -1754,6 +1844,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) { state.search = SearchState::default(); } state.global.stack.clear(); + state.fed_artist_view = None; } Tab::Playlists => state.playlists.opened = None, Tab::Federation => state.federation.cursor = 0, diff --git a/src/federation/audio.rs b/src/federation/audio.rs index 3d8f365..8fe0da2 100644 --- a/src/federation/audio.rs +++ b/src/federation/audio.rs @@ -168,7 +168,7 @@ fn extension_for_mime(mime: &str) -> &'static str { } /// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`]. -async fn read_line(reader: &mut R) -> Result> { +pub(super) async fn read_line(reader: &mut R) -> Result> { let mut line = Vec::new(); let mut byte = [0u8; 1]; loop { diff --git a/src/federation/catalog.rs b/src/federation/catalog.rs new file mode 100644 index 0000000..6087863 --- /dev/null +++ b/src/federation/catalog.rs @@ -0,0 +1,370 @@ +//! The peer catalog protocol: one peer asks another for its library slice +//! of a single artist (releases with full tracklists), used to assemble a +//! federated artist card. +//! +//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one +//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one +//! JSON document ([`CatalogResponse`]) and finishes. All fields default, so +//! the shape is extensible like the audio protocol. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use music_dht::{ByteStream, EndpointId, ItemKind, MusicDhtService, StreamAcceptor}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; + +use crate::library::Library; + +/// ALPN of the catalog protocol. +pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1"; + +/// Upper bound for one catalog response (thousands of tracks fit easily). +const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +struct CatalogRequest { + /// Artist display name; matched case-insensitively by the owner. + artist: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CatalogResponse { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + artist: Option, +} + +/// One peer's library slice for an artist. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CatalogArtist { + #[serde(default)] + pub name: String, + #[serde(default)] + pub releases: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CatalogRelease { + #[serde(default)] + pub title: String, + #[serde(default)] + pub release_type: String, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub tracks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CatalogTrack { + #[serde(default)] + pub title: String, + #[serde(default)] + pub track_number: Option, + #[serde(default)] + pub disc_number: Option, + #[serde(default)] + pub duration_seconds: Option, + /// Hex DHT item id — the key the audio is requested by (FedPlay). + #[serde(default)] + pub item_id: String, +} + +// --------------------------------------------------------------------------- +// Serving side +// --------------------------------------------------------------------------- + +/// Runs the catalog accept loop until the acceptor closes. +pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc, own: EndpointId) { + while let Some(stream) = acceptor.accept().await { + let library = Arc::clone(&library); + tokio::spawn(async move { + let peer = stream.peer_id; + if let Err(err) = serve_one(stream, library, own).await { + tracing::warn!(peer = %peer, "catalog request failed: {err:#}"); + } + }); + } +} + +async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointId) -> Result<()> { + let request: CatalogRequest = + serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?; + tracing::info!(peer = %stream.peer_id, artist = %request.artist, "peer requested a catalog"); + + let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist)) + .await? + .unwrap_or_else(|err| CatalogResponse { + ok: false, + error: Some(format!("catalog lookup failed: {err:#}")), + artist: None, + }); + let payload = serde_json::to_vec(&response)?; + stream.send.write_all(&payload).await?; + stream.send.finish()?; + let _ = stream.send.stopped().await; + Ok(()) +} + +/// Builds this instance's library slice for `artist`. +fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result { + let Some(artist_id) = library.artist_id_by_name(artist)? else { + return Ok(CatalogResponse { + ok: false, + error: Some("artist not found in the library".to_string()), + artist: None, + }); + }; + let detail = library.artist(artist_id)?; + let item_id_of = |track_id: i64| -> String { + super::audio::hex_encode( + music_dht::ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}")) + .as_bytes(), + ) + }; + let mut releases = Vec::new(); + for card in &detail.releases { + let release = library.release(card.id)?; + releases.push(CatalogRelease { + title: release.title, + release_type: release.release_type, + year: release.year, + tracks: release + .tracks + .iter() + .map(|track| CatalogTrack { + title: track.title.clone(), + track_number: track.track_number, + disc_number: track.disc_number, + duration_seconds: (track.duration_seconds > 0.0) + .then_some(track.duration_seconds), + item_id: item_id_of(track.id), + }) + .collect(), + }); + } + Ok(CatalogResponse { + ok: true, + error: None, + artist: Some(CatalogArtist { + name: detail.name, + releases, + }), + }) +} + +// --------------------------------------------------------------------------- +// Requesting side +// --------------------------------------------------------------------------- + +/// Fetches one peer's catalog slice for `artist`. +pub async fn fetch_catalog( + service: &MusicDhtService, + owner: EndpointId, + artist: &str, +) -> Result { + let mut stream = service + .open_stream(owner, CATALOG_ALPN) + .await + .map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?; + let mut line = serde_json::to_vec(&CatalogRequest { + artist: artist.to_string(), + })?; + line.push(b'\n'); + stream.send.write_all(&line).await?; + stream.send.finish()?; + + let mut payload = Vec::new(); + // The whole response is one JSON document, bounded by the byte cap. + tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1) + .read_to_end(&mut payload) + .await?; + anyhow::ensure!( + payload.len() as u64 <= MAX_CATALOG_BYTES, + "catalog response exceeds {MAX_CATALOG_BYTES} bytes" + ); + let response: CatalogResponse = + serde_json::from_slice(&payload).context("malformed catalog response")?; + if !response.ok { + anyhow::bail!( + "peer refused the catalog: {}", + response + .error + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + response.artist.context("empty catalog response") +} + +/// AsyncRead adapter over the receive half of a byte stream. +struct StreamReader<'a>(&'a mut ByteStream); + +impl tokio::io::AsyncRead for StreamReader<'_> { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.0.recv).poll_read(cx, buf) + } +} + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +/// The assembled, deduplicated federated artist card. +#[derive(Debug, Clone, Default)] +pub struct FedArtistCard { + #[allow(dead_code, reason = "the open card is keyed by name in AppState")] + pub name: String, + /// Peers whose catalogs contributed to the card. + pub peers: usize, + pub releases: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct FedRelease { + pub title: String, + pub release_type: String, + pub year: Option, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct FedCardTrack { + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: Option, + /// Every peer that can serve this track: (owner hex, item id hex). + /// Duplicates collapse into one row; all sources stay playable. + pub sources: Vec<(String, String)>, +} + +/// Merges per-peer catalogs into one card: releases are keyed by normalized +/// title, tracks within a release by normalized title + track number; a +/// track present on several peers keeps every source. +pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> FedArtistCard { + let peers = catalogs.len(); + let mut releases: Vec = Vec::new(); + let mut release_index: HashMap = HashMap::new(); + + for (owner_hex, catalog) in catalogs { + for release in catalog.releases { + let release_key = music_dht::normalize_name(&release.title); + let slot = *release_index.entry(release_key).or_insert_with(|| { + releases.push(FedRelease { + title: release.title.clone(), + release_type: release.release_type.clone(), + year: None, + tracks: Vec::new(), + }); + releases.len() - 1 + }); + let merged = &mut releases[slot]; + if merged.year.is_none() { + merged.year = release.year; + } + if merged.release_type.is_empty() { + merged.release_type = release.release_type.clone(); + } + for track in release.tracks { + if track.item_id.is_empty() { + continue; + } + let existing = merged.tracks.iter_mut().find(|t| { + music_dht::normalize_name(&t.title) == music_dht::normalize_name(&track.title) + && (t.track_number == track.track_number + || t.track_number.is_none() + || track.track_number.is_none()) + }); + match existing { + Some(t) => { + if t.track_number.is_none() { + t.track_number = track.track_number; + } + if t.duration_seconds.is_none() { + t.duration_seconds = track.duration_seconds; + } + t.sources.push((owner_hex.clone(), track.item_id)); + } + None => merged.tracks.push(FedCardTrack { + title: track.title, + track_number: track.track_number, + disc_number: track.disc_number, + duration_seconds: track.duration_seconds, + sources: vec![(owner_hex.clone(), track.item_id)], + }), + } + } + } + } + + for release in &mut releases { + release + .tracks + .sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(i32::MAX))); + } + releases.sort_by(|a, b| { + a.year + .unwrap_or(i32::MAX) + .cmp(&b.year.unwrap_or(i32::MAX)) + .then_with(|| a.title.cmp(&b.title)) + }); + + FedArtistCard { + name: name.to_string(), + peers, + releases, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(title: &str, number: i32, item: &str) -> CatalogTrack { + CatalogTrack { + title: title.into(), + track_number: Some(number), + disc_number: None, + duration_seconds: Some(100.0), + item_id: item.into(), + } + } + + #[test] + fn merges_and_dedupes_across_peers() { + let catalog = |item_prefix: &str| CatalogArtist { + name: "Metallica".into(), + releases: vec![CatalogRelease { + title: "Black Album".into(), + release_type: "album".into(), + year: Some(1991), + tracks: vec![ + track("Enter Sandman", 1, &format!("{item_prefix}1")), + track("Sad But True", 2, &format!("{item_prefix}2")), + ], + }], + }; + let card = merge_catalogs( + "Metallica", + vec![ + ("peer-a".to_string(), catalog("a")), + ("peer-b".to_string(), catalog("b")), + ], + ); + assert_eq!(card.peers, 2); + assert_eq!(card.releases.len(), 1); + let release = &card.releases[0]; + assert_eq!(release.year, Some(1991)); + assert_eq!(release.tracks.len(), 2); + // Both peers stay as sources of the deduplicated track. + assert_eq!(release.tracks[0].sources.len(), 2); + } +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 822d23f..4bcd102 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -13,6 +13,7 @@ //! the network too). mod audio; +pub mod catalog; use std::path::PathBuf; use std::str::FromStr; @@ -31,6 +32,7 @@ use crate::library::Library; use crate::library::models::{ArtistRef, TrackItem}; pub use audio::{AUDIO_ALPN, TrackMetadata}; +pub use catalog::{CATALOG_ALPN, FedArtistCard, FedCardTrack, FedRelease}; /// How often the published library is re-synchronized with the local index. const SYNC_INTERVAL: Duration = Duration::from_secs(60); @@ -85,6 +87,23 @@ fn save_settings(settings: &FedSettings) -> Result<()> { // Data shapes for the UI // --------------------------------------------------------------------------- +/// An artist surfaced by federated search — either an artist record, or +/// derived from the artist names of matching tracks/releases (so searching +/// a track title still leads to the artist's card). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FedArtistHit { + pub name: String, + /// Distinct peers (other than this instance) holding the artist. + pub peers: usize, +} + +/// Federated search results for the UI. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FedSearchResults { + pub artists: Vec, + pub tracks: Vec, +} + /// A track found through federated search. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FedTrack { @@ -261,6 +280,8 @@ impl Federation { .rendezvous(RendezvousConfig::default()) // Peers stream each other's audio over this protocol. .stream_protocol(AUDIO_ALPN) + // ...and browse each other's per-artist catalogs over this one. + .stream_protocol(CATALOG_ALPN) .build() .map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?; let (service, mut events) = MusicDhtService::start(config) @@ -298,11 +319,20 @@ impl Federation { Arc::clone(&self.library), service.endpoint_id(), )); + // Serve per-artist catalog requests (the federated artist card). + let catalog_acceptor = service + .stream_acceptor(CATALOG_ALPN) + .map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?; + let catalog_task = tokio::spawn(catalog::serve_peers( + catalog_acceptor, + Arc::clone(&self.library), + service.endpoint_id(), + )); *guard = Some(Running { service, network_name, - tasks: vec![event_task, sync_task, audio_task], + tasks: vec![event_task, sync_task, audio_task, catalog_task], }); self.set_error(None); Ok(()) @@ -403,15 +433,17 @@ impl Federation { status } - /// Searches the federated network for tracks matching `query`. - pub async fn search(&self, query: &str) -> Result> { + /// Searches the federated network: matching tracks plus the artists a + /// card can be assembled for (from artist records and from the artist + /// names of matching tracks/releases). + pub async fn search(&self, query: &str) -> Result { let service = self.service().await?; let outcome = service .search_network(query) .await .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; let own = service.endpoint_id(); - Ok(outcome + let tracks: Vec = outcome .network_results .iter() .filter(|item| item.kind == ItemKind::Track) @@ -424,7 +456,106 @@ impl Federation { year: item.year, duration_seconds: item.duration_seconds.map(|d| d.round() as i64), }) - .collect()) + .collect(); + + // Artists: normalized name -> (display name, distinct non-own peers). + let mut artists: std::collections::HashMap< + String, + (String, std::collections::HashSet), + > = Default::default(); + for item in &outcome.network_results { + if item.owner == own { + continue; + } + let mut note = |name: &str| { + let key = music_dht::normalize_name(name); + if key.is_empty() { + return; + } + let entry = artists + .entry(key) + .or_insert_with(|| (name.to_string(), Default::default())); + entry.1.insert(item.owner); + }; + if item.kind == ItemKind::Artist { + note(&item.name); + } + for artist in &item.artist_names { + note(artist); + } + } + let mut artists: Vec = artists + .into_values() + .map(|(name, owners)| FedArtistHit { + name, + peers: owners.len(), + }) + .collect(); + artists.sort_by(|a, b| b.peers.cmp(&a.peers).then_with(|| a.name.cmp(&b.name))); + + Ok(FedSearchResults { artists, tracks }) + } + + /// Assembles the federated artist card: finds the peers holding the + /// artist through the DHT, asks each for its catalog slice directly and + /// merges the answers (missing/slow peers are skipped). + pub async fn artist_card(&self, name: &str) -> Result { + let service = self.service().await?; + let own = service.endpoint_id(); + let normalized = music_dht::normalize_name(name); + let outcome = service + .search_network(name) + .await + .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; + let owners: std::collections::HashSet = outcome + .network_results + .iter() + .filter(|item| { + (item.kind == ItemKind::Artist && item.normalized_name == normalized) + || item + .artist_names + .iter() + .any(|artist| music_dht::normalize_name(artist) == normalized) + }) + .map(|item| item.owner) + .filter(|owner| *owner != own) + .collect(); + anyhow::ensure!(!owners.is_empty(), "no peers hold artist \"{name}\""); + + let mut requests = Vec::new(); + for owner in owners { + let service = Arc::clone(&service); + let name = name.to_string(); + requests.push(tokio::spawn(async move { + let result = tokio::time::timeout( + Duration::from_secs(5), + catalog::fetch_catalog(&service, owner, &name), + ) + .await; + match result { + Ok(Ok(catalog)) => Some((owner.to_string(), catalog)), + Ok(Err(err)) => { + tracing::warn!(peer = %owner, "catalog fetch failed: {err:#}"); + None + } + Err(_) => { + tracing::warn!(peer = %owner, "catalog fetch timed out"); + None + } + } + })); + } + let mut catalogs = Vec::new(); + for request in requests { + if let Ok(Some(catalog)) = request.await { + catalogs.push(catalog); + } + } + anyhow::ensure!( + !catalogs.is_empty(), + "none of the peers answered the catalog request" + ); + Ok(catalog::merge_catalogs(name, catalogs)) } pub async fn ticket(&self) -> Result { diff --git a/src/library/mod.rs b/src/library/mod.rs index 6b60a78..4e3d40f 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -719,6 +719,18 @@ impl Library { Ok(()) } + /// Artist id by display name (case-insensitive), for catalog requests. + pub fn artist_id_by_name(&self, name: &str) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE", + [name], + |row| row.get(0), + ) + .optional()?) + } + /// Image of one artist, for the federation metadata exchange. pub fn artist_image(&self, artist_id: i64) -> Result> { let conn = self.lock(); diff --git a/src/ui/global.rs b/src/ui/global.rs index 1625a96..0175a2b 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -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, Option)> = Vec::new(); + rows.push(( + Line::styled( + format!( + "{} релизов · {} треков · с {} пиров", + card.releases.len(), + card.releases.iter().map(|r| r.tracks.len()).sum::(), + 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)); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4ff4b02..4dc8c2a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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> { + let width = width.max(2); + let chars: Vec = 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), + ] +} diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 411d744..0c2c528 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -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 = 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())