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, list_state: ListState, /// The `/` search filter; selection and rendering go through it. filter: Filter, parent: Option, positions: HashMap, /// Visual (paint-select) mode: while true, movement toggles the mark of /// every row swept over (architecture/visual-mode.md). visual: bool, 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(), filter: Filter::default(), positions: HashMap::new(), parent: None, visual: false, 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) { 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> { // 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(view) = self.list_state.selected() { self.toggle_mark_view(view); } } /// Toggle the mark of the row at a **view** index (mapped through the `/` /// filter to its real row), honoring the `is_queable` gate. Shared by /// `toggle_mark` and visual-mode painting. fn toggle_mark_view(&mut self, view: usize) { if let Some(real) = self.filter.to_real(view) { let item = &mut self.list[real]; if !item.is_queable { return; } item.marked = !item.marked; } } /// Whether visual (paint-select) mode is active. pub fn is_visual(&self) -> bool { self.visual } /// Titles of the currently marked rows, in list order (inspection helper). pub fn marked_titles(&self) -> Vec { self.list .iter() .filter(|i| i.marked) .map(|i| i.title.clone()) .collect() } /// Enter or leave visual mode. Entering also toggles the current row's /// mark (vim includes the row you start on, D2); leaving keeps the marks. pub fn toggle_visual(&mut self) { self.visual = !self.visual; if self.visual { self.toggle_mark(); } } /// Leave visual mode (marks kept). Idempotent. pub fn exit_visual(&mut self) { self.visual = false; } /// The current cursor position as a view index. pub fn selected_view(&self) -> Option { self.list_state.selected() } /// Paint the sweep from `from_view` to `to_view`: toggle the mark of every /// view index in the half-open range `(from_view, to_view]` (excludes the /// row left, includes each row swept into, in either direction). Used after /// a movement while visual mode is active. pub fn paint_between(&mut self, from_view: usize, to_view: usize) { if from_view == to_view { return; } if to_view > from_view { for view in (from_view + 1)..=to_view { self.toggle_mark_view(view); } } else { for view in to_view..from_view { self.toggle_mark_view(view); } } } 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 = 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() }; if i.is_creatable { text.push_str(" [%]"); } // Modifiable items advertise their action 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(']'); } // A trailing `↓` (a status, not an action, so kept outside // the key brackets) marks a row whose audio is in the content // store (downloaded). if i.is_captured { text.push_str(" ↓"); } 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 self.visual { // Visual (paint-select) mode: movement toggles marks. format!("{} — VISUAL", self.title) } else 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) { // 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 { 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_trailing_arrow() { 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 trailing down-arrow: {text}" ); } }