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, list_state: ListState, parent: Option, positions: HashMap, tx: Sender, } impl Library { pub fn new(tx: Sender) -> 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 { 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> { 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 = 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) { 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 { self.list_state.selected() } }