crabidy/cbd-tui/src/app/library.rs

293 lines
9.9 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::{
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
COLOR_PRIMARY_DARK, 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,
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(),
positions: HashMap::new(),
parent: None,
tx,
}
}
/// 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.list.get(self.list_state.selected()?)?;
item.is_editable
.then(|| (item.path.clone(), item.title.clone()))
}
/// Path 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> {
let item = self.list.get(self.list_state.selected()?)?;
item.is_deletable.then(|| item.path.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.list.get(self.list_state.selected()?)?;
item.is_queable
.then(|| (item.path.clone(), item.title.clone()))
}
pub fn get_selected(&self) -> Option<Vec<String>> {
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.list.get(self.list_state.selected()?)?;
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(idx) = self.list_state.selected() {
let item = &self.list[idx];
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(idx) = self.list_state.selected() {
let item = &mut self.list[idx];
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;
self.select(Some(self.prev_selected()));
// 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,
is_deletable: false,
})
.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,
}))
.collect();
self.update_selection();
}
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
let library_items: Vec<ListItem> = self
.list
.iter()
.map(|i| {
let mut text = if i.marked {
format!("* {}", i.title)
} else {
i.title.to_string()
};
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 style = if i.marked {
Style::default()
.fg(COLOR_GREEN)
.add_modifier(Modifier::BOLD)
} else if i.is_creatable || i.is_editable || i.is_deletable {
Style::default().fg(COLOR_SECONDARY)
} else {
Style::default()
};
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 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 {
self.list.len()
}
fn select(&mut self, idx: Option<usize>) {
if let Some(pos) = idx {
self.positions
.entry(self.path.clone())
.and_modify(|e| *e = pos)
.or_insert(pos);
}
self.list_state.select(idx);
}
fn selected(&self) -> Option<usize> {
self.list_state.selected()
}
}