422 lines
15 KiB
Rust
422 lines
15 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use flume::Sender;
|
|
use ratatui::{
|
|
layout::Rect,
|
|
style::{Modifier, Style},
|
|
text::Span,
|
|
widgets::{Block, BorderType, Borders, List, ListItem, ListState},
|
|
Frame,
|
|
};
|
|
|
|
use crabidy_core::proto::crabidy::LibraryNode;
|
|
|
|
use super::{
|
|
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
|
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
|
};
|
|
|
|
pub struct Library {
|
|
title: String,
|
|
path: String,
|
|
/// Whether children may be created under the currently open node
|
|
/// (mirrors `LibraryNode.is_creatable`). Drives the `%` action and the
|
|
/// pane-title hint.
|
|
is_creatable: bool,
|
|
list: Vec<UiItem>,
|
|
list_state: ListState,
|
|
/// The `/` search filter; selection and rendering go through it.
|
|
filter: Filter,
|
|
parent: Option<String>,
|
|
positions: HashMap<String, usize>,
|
|
tx: Sender<MessageFromUi>,
|
|
}
|
|
|
|
impl Library {
|
|
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
|
Self {
|
|
title: "Library".to_string(),
|
|
path: crabidy_core::ROOT_PATH.to_string(),
|
|
is_creatable: false,
|
|
list: Vec::new(),
|
|
list_state: ListState::default(),
|
|
filter: Filter::default(),
|
|
positions: HashMap::new(),
|
|
parent: None,
|
|
tx,
|
|
}
|
|
}
|
|
|
|
/// The item under the cursor, mapped through the active filter.
|
|
fn resolved(&self) -> Option<&UiItem> {
|
|
let real = self.filter.to_real(self.list_state.selected()?)?;
|
|
self.list.get(real)
|
|
}
|
|
|
|
/// Sets (or clears) the `/` search query and re-selects the first
|
|
/// match so the cursor never points at a now-hidden row.
|
|
pub fn set_filter(&mut self, query: Option<String>) {
|
|
self.filter
|
|
.set(query, self.list.iter().map(|i| i.title.as_str()));
|
|
self.update_selection();
|
|
}
|
|
|
|
/// The active search query, if the pane is in search mode.
|
|
pub fn filter_query(&self) -> Option<&str> {
|
|
self.filter.query()
|
|
}
|
|
|
|
/// Path of the currently open node.
|
|
pub fn path(&self) -> &str {
|
|
&self.path
|
|
}
|
|
|
|
/// Whether the currently open node accepts child creation (`%`).
|
|
pub fn is_creatable(&self) -> bool {
|
|
self.is_creatable
|
|
}
|
|
/// Path and current title of the selected item, if it may be renamed
|
|
/// (`e`). `None` when nothing is selected or the item is not editable.
|
|
pub fn selected_editable(&self) -> Option<(String, String)> {
|
|
let item = self.resolved()?;
|
|
item.is_editable
|
|
.then(|| (item.path.clone(), item.title.clone()))
|
|
}
|
|
/// Path and title of the selected item, if it may be deleted (`d`).
|
|
/// `None` when nothing is selected or the item is not deletable.
|
|
pub fn selected_deletable(&self) -> Option<(String, String)> {
|
|
let item = self.resolved()?;
|
|
item.is_deletable
|
|
.then(|| (item.path.clone(), item.title.clone()))
|
|
}
|
|
/// Path and title of the bare selection, if it is queueable — what `w`
|
|
/// captures as a bookmark. Marks are deliberately ignored: one capture
|
|
/// per invocation (architecture/bookmarks.md D5).
|
|
pub fn selected_queueable(&self) -> Option<(String, String)> {
|
|
let item = self.resolved()?;
|
|
item.is_queable
|
|
.then(|| (item.path.clone(), item.title.clone()))
|
|
}
|
|
/// The bare selection's path and title when it can be captured with a
|
|
/// download (`W`): queueable *and* downloadable. Marks are ignored,
|
|
/// like [`Self::selected_queueable`].
|
|
pub fn selected_downloadable(&self) -> Option<(String, String)> {
|
|
let item = self.resolved()?;
|
|
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
|
|
}
|
|
pub fn get_selected(&self) -> Option<Vec<String>> {
|
|
// Marks live on the full list; a hidden marked item still counts
|
|
// (the filter narrows what you *see*, not what you already chose).
|
|
if self.list.iter().any(|i| i.marked) {
|
|
return Some(
|
|
self.list
|
|
.iter()
|
|
.filter(|i| i.marked)
|
|
.map(|i| i.path.to_string())
|
|
.collect(),
|
|
);
|
|
}
|
|
// Marks are gated on is_queable when set; the bare selection must be
|
|
// gated here too, or Enter on a plain folder ships a path the server
|
|
// can only resolve to nothing (silently ignored, like % / e / d on
|
|
// items without the capability).
|
|
let item = self.resolved()?;
|
|
item.is_queable.then(|| vec![item.path.to_string()])
|
|
}
|
|
pub fn ascend(&mut self) {
|
|
if let Some(parent) = self.parent.as_ref() {
|
|
let _ = self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
|
|
}
|
|
}
|
|
pub fn dive(&mut self) {
|
|
if let Some(item) = self.resolved() {
|
|
if let UiItemKind::Node = item.kind {
|
|
let _ = self
|
|
.tx
|
|
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
|
|
}
|
|
}
|
|
}
|
|
pub fn queue_append(&mut self) {
|
|
if let Some(items) = self.get_selected() {
|
|
match self.tx.send(MessageFromUi::AppendTracks(items)) {
|
|
Ok(_) => self.remove_marks(),
|
|
Err(_) => { /* FIXME: warn */ }
|
|
}
|
|
}
|
|
}
|
|
pub fn queue_queue(&mut self) {
|
|
if let Some(items) = self.get_selected() {
|
|
match self.tx.send(MessageFromUi::QueueTracks(items)) {
|
|
Ok(_) => self.remove_marks(),
|
|
Err(_) => { /* FIXME: warn */ }
|
|
}
|
|
}
|
|
}
|
|
pub fn queue_replace(&mut self) {
|
|
if let Some(items) = self.get_selected() {
|
|
match self.tx.send(MessageFromUi::ReplaceQueue(items)) {
|
|
Ok(_) => self.remove_marks(),
|
|
Err(_) => { /* FIXME: warn */ }
|
|
}
|
|
}
|
|
}
|
|
pub fn queue_insert(&mut self, pos: usize) {
|
|
if let Some(items) = self.get_selected() {
|
|
match self.tx.send(MessageFromUi::InsertTracks(items, pos)) {
|
|
Ok(_) => self.remove_marks(),
|
|
Err(_) => { /* FIXME: warn */ }
|
|
}
|
|
}
|
|
}
|
|
pub fn prev_selected(&self) -> usize {
|
|
*self.positions.get(&self.path).unwrap_or(&0)
|
|
}
|
|
pub fn toggle_mark(&mut self) {
|
|
if let Some(real) = self
|
|
.list_state
|
|
.selected()
|
|
.and_then(|view| self.filter.to_real(view))
|
|
{
|
|
let item = &mut self.list[real];
|
|
if !item.is_queable {
|
|
return;
|
|
}
|
|
item.marked = !item.marked;
|
|
}
|
|
}
|
|
pub fn remove_marks(&mut self) {
|
|
if self.list.iter().any(|i| i.marked) {
|
|
self.list
|
|
.iter_mut()
|
|
.filter(|i| i.marked)
|
|
.for_each(|i| i.marked = false);
|
|
}
|
|
}
|
|
pub fn update(&mut self, node: LibraryNode) {
|
|
// Creatable nodes (e.g. an empty search node) must be enterable even
|
|
// with nothing in them — the user goes there to create children.
|
|
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// if children empty and tracks empty return
|
|
self.path = node.path;
|
|
self.title = node.title;
|
|
self.parent = node.parent;
|
|
self.is_creatable = node.is_creatable;
|
|
|
|
// Most nodes carry either children or tracks; search term nodes
|
|
// carry both (track results + artist/album results), so the list is
|
|
// the concatenation. Tracks first: they are the primary search hits
|
|
// (architecture/search.md fixes the order tracks, artists, albums).
|
|
self.list = node
|
|
.tracks
|
|
.iter()
|
|
.map(|t| UiItem {
|
|
path: t.path.clone(),
|
|
title: format!("{} - {}", t.artist, t.title),
|
|
kind: UiItemKind::Track,
|
|
marked: false,
|
|
is_queable: true,
|
|
is_creatable: false,
|
|
is_editable: false,
|
|
// Tracks carry no wire flags of their own: they inherit
|
|
// their node's blessing (architecture/captures.md D4).
|
|
is_deletable: node.tracks_deletable,
|
|
is_downloadable: node.is_downloadable,
|
|
is_skipped: t.is_skipped,
|
|
is_captured: t.is_captured,
|
|
})
|
|
.chain(node.children.iter().map(|c| UiItem {
|
|
path: c.path.clone(),
|
|
title: c.title.clone(),
|
|
kind: UiItemKind::Node,
|
|
marked: false,
|
|
is_queable: c.is_queable,
|
|
is_creatable: c.is_creatable,
|
|
is_editable: c.is_editable,
|
|
is_deletable: c.is_deletable,
|
|
is_downloadable: c.is_downloadable,
|
|
is_skipped: false,
|
|
is_captured: c.is_captured,
|
|
}))
|
|
.collect();
|
|
|
|
// A new node is a fresh listing: leave search mode and show all.
|
|
self.filter
|
|
.set(None, self.list.iter().map(|i| i.title.as_str()));
|
|
// With no filter the view index is the real index, so the
|
|
// remembered cursor position restores directly.
|
|
self.select(Some(self.prev_selected()));
|
|
self.update_selection();
|
|
}
|
|
|
|
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
|
let selected = self.list_state.selected();
|
|
// Render only the rows the filter keeps visible; `view` is the
|
|
// rendered index the selection bar keys off, `i` the real item.
|
|
let library_items: Vec<ListItem> = self
|
|
.filter
|
|
.visible()
|
|
.iter()
|
|
.map(|&real| &self.list[real])
|
|
.enumerate()
|
|
.map(|(idx, i)| {
|
|
let mut text = if i.marked {
|
|
format!("* {}", i.title)
|
|
} else {
|
|
i.title.to_string()
|
|
};
|
|
// Captured rows carry a leading `|` before every other
|
|
// prefix (selection padding, the `* ` mark): the very first
|
|
// character of the row marks the audio as present in the
|
|
// capture content store.
|
|
if i.is_captured {
|
|
text.insert(0, '|');
|
|
}
|
|
if i.is_creatable {
|
|
text.push_str(" [%]");
|
|
}
|
|
// Modifiable items advertise their keys: [e], [d] or [ed].
|
|
if i.is_editable || i.is_deletable {
|
|
text.push_str(" [");
|
|
if i.is_editable {
|
|
text.push('e');
|
|
}
|
|
if i.is_deletable {
|
|
text.push('d');
|
|
}
|
|
text.push(']');
|
|
}
|
|
let mut style = if i.marked {
|
|
Style::default()
|
|
.fg(COLOR_GREEN)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else if i.is_skipped {
|
|
// Skipped tracks have no playable audio; playback
|
|
// skips them (architecture/incremental-captures.md).
|
|
Style::default().fg(COLOR_RED)
|
|
} else if i.is_creatable || i.is_editable || i.is_deletable {
|
|
Style::default().fg(COLOR_SECONDARY)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
// A colored foreground is unreadable on the light focused
|
|
// selection bar — switch it to the dark tone there (D7).
|
|
if focused && selected == Some(idx) && style.fg.is_some() {
|
|
style = style.fg(COLOR_PRIMARY_DARK);
|
|
}
|
|
ListItem::new(Span::from(text)).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let library_list = List::new(library_items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(if focused {
|
|
COLOR_PRIMARY
|
|
} else {
|
|
COLOR_PRIMARY_DARK
|
|
}))
|
|
.title(if let Some(query) = self.filter.query() {
|
|
// Search mode: show the live query with a cursor.
|
|
format!("{} — /{query}▏", self.title)
|
|
} else if self.is_creatable {
|
|
format!("{} — % to add", self.title)
|
|
} else {
|
|
self.title.clone()
|
|
}),
|
|
)
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(if focused {
|
|
COLOR_PRIMARY
|
|
} else {
|
|
COLOR_PRIMARY_DARK
|
|
})
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(library_list, area, &mut self.list_state);
|
|
}
|
|
}
|
|
|
|
impl StatefulList for Library {
|
|
fn get_size(&self) -> usize {
|
|
// Navigation operates on the filtered (visible) view.
|
|
self.filter.view_len()
|
|
}
|
|
|
|
fn select(&mut self, idx: Option<usize>) {
|
|
// Remember the cursor per node as a real index so it survives a
|
|
// filter (which only reorders the view). With no filter, the
|
|
// view index is already the real index.
|
|
if let Some(view) = idx {
|
|
if let Some(real) = self.filter.to_real(view) {
|
|
self.positions
|
|
.entry(self.path.clone())
|
|
.and_modify(|e| *e = real)
|
|
.or_insert(real);
|
|
}
|
|
}
|
|
self.list_state.select(idx);
|
|
}
|
|
|
|
fn selected(&self) -> Option<usize> {
|
|
self.list_state.selected()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crabidy_core::proto::crabidy::LibraryNodeChild;
|
|
|
|
/// Renders a `Library` into an 80x24 test terminal and returns its text.
|
|
fn render_text(library: &mut Library) -> String {
|
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
|
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| library.render(f, f.area(), true))
|
|
.expect("draw library");
|
|
let buf = terminal.backend().buffer();
|
|
let mut text = String::new();
|
|
for y in 0..buf.area.height {
|
|
for x in 0..buf.area.width {
|
|
text.push_str(buf[(x, y)].symbol());
|
|
}
|
|
text.push('\n');
|
|
}
|
|
text
|
|
}
|
|
|
|
#[test]
|
|
fn captured_rows_render_a_leading_pipe() {
|
|
let (tx, _rx) = flume::unbounded();
|
|
let mut library = Library::new(tx);
|
|
library.update(LibraryNode {
|
|
path: "/crabidy/mix".to_string(),
|
|
title: "mix".to_string(),
|
|
children: vec![LibraryNodeChild {
|
|
is_captured: true,
|
|
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
|
|
}],
|
|
parent: Some("/crabidy".to_string()),
|
|
tracks: Vec::new(),
|
|
is_queable: true,
|
|
is_creatable: false,
|
|
is_downloadable: false,
|
|
tracks_deletable: false,
|
|
is_captured: false,
|
|
});
|
|
let text = render_text(&mut library);
|
|
assert!(
|
|
text.contains("|album"),
|
|
"captured row carries a leading pipe: {text}"
|
|
);
|
|
}
|
|
}
|