From 87d66f2f401d82add48e29485210856a41eeaafa Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Tue, 20 Oct 2026 10:45:04 -0500 Subject: [PATCH] Add a command palette on : and Ctrl+P --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 11 +- src/tui/app.rs | 126 +++++++- src/tui/keymap.rs | 62 ++++ src/tui/mod.rs | 1 + src/tui/pages/discover.rs | 5 + src/tui/pages/episode.rs | 4 + src/tui/pages/episodes.rs | 4 + src/tui/pages/home.rs | 20 +- src/tui/pages/mod.rs | 12 +- src/tui/pages/podcasts.rs | 4 + src/tui/pages/queue.rs | 4 + src/tui/pages/saved.rs | 4 + src/tui/pages/search.rs | 62 +++- src/tui/palette.rs | 636 ++++++++++++++++++++++++++++++++++++++ src/tui/task.rs | 6 + 17 files changed, 957 insertions(+), 16 deletions(-) create mode 100644 src/tui/palette.rs diff --git a/Cargo.lock b/Cargo.lock index ebcc1c5..56dc5ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,6 +2295,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -2822,6 +2832,7 @@ dependencies = [ "html2text", "keyring", "libc", + "nucleo-matcher", "ratatui", "reqwest", "rodio", diff --git a/Cargo.toml b/Cargo.toml index 7d88b81..74e4302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ futures-util = { version = "0.3.34", default-features = false, features = ["sink getrandom = "0.3.4" html2text = "0.17.4" keyring = "4.2.0" +nucleo-matcher = "0.3.1" ratatui = { version = "0.30.2", features = ["serde"] } reqwest = { version = "0.13.5", features = ["json", "query", "gzip"] } rodio = { version = "0.23.0", default-features = false, features = ["playback", "mp3", "mp4", "flac", "vorbis", "wav"] } diff --git a/README.md b/README.md index 1cfc52b..7b08397 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A TUI (terminal user interface) podcast client for PinePods. Stream your podcast - **Pages**: Home, Feed, Podcasts, Queue, Saved (with your collections), Playlists, History, Downloads, Search, Devices, Now Playing and Settings, plus episode details with show notes, chapters and transcripts - **Find and follow podcasts**: search Podcast Index, iTunes and YouTube and subscribe from the terminal - **Filter as you type**: `/` narrows any list; Search queries your server as you type +- **Command palette**: `:` or Ctrl+P finds any command, page, podcast or loaded episode by a few letters of its name - **Themes**: the same 25 themes as the PinePods web app, starting with the one you picked there, or your terminal's own colors - **Keys you can change**: vim-style and arrow keys by default, `?` lists them, `keymap.toml` rebinds them - **Mouse**: click to select, click again to play or open, scroll lists, click the progress bar to seek @@ -105,6 +106,7 @@ Every key you type goes into the fields, so **Ctrl+C** quits the login screen. * - **j/k** or **↑/↓** move, **g/G** first/last, **Ctrl+D/U** page down/up - **Enter** plays an episode or opens a podcast. **i** (or **l**) opens an episode's details and show notes. **Esc** goes back - **/** filters the list you're on; **Enter** keeps the filter, **Esc** clears it +- **:** or **Ctrl+P** opens the command palette: type a few letters of a command, a page ("go queue"), one of your podcasts, an episode on screen somewhere ("play …") or a recent search, then **Enter** - **?** shows every key for the page you're on. **q** quits (**Ctrl+C** works anywhere, even mid-typing) ### Playing @@ -173,6 +175,7 @@ src/ └── tui/ # Terminal user interface ├── app.rs # Sidebar, now-playing bar, overlays, input routing ├── keymap.rs # Default bindings and keymap.toml + ├── palette.rs # The command palette ├── task.rs # Background loading ├── ui/ # Shared widgets: lists, episode rows, show notes └── pages/ # The screens @@ -248,10 +251,10 @@ This project is licensed under the GNU General Public License v3.0; see the [LIC ## 🗺️ Roadmap -- [ ] History, playlists and collections -- [ ] Chapters and transcripts -- [ ] Podcast discovery and subscribing -- [ ] A command palette +- [x] History, playlists and collections +- [x] Chapters and transcripts +- [x] Podcast discovery and subscribing +- [x] A command palette - [ ] Cover art in terminals that can show images - [ ] Media keys and the system's now-playing controls (MPRIS on Linux, Now Playing on macOS) - [ ] Headless mode, for a Raspberry Pi or speaker box that other devices play on diff --git a/src/tui/app.rs b/src/tui/app.rs index 5324706..a80ae66 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -22,7 +22,10 @@ use unicode_width::UnicodeWidthStr; use super::collection_picker::CollectionPicker; use super::device_picker::DevicePicker; use super::keymap::{Action, Context, Keymap}; +use super::pages::episodes::EpisodeListPage; +use super::pages::search::{RecentSearch, SearchPage}; use super::pages::{Ctx, Outcome, Page, Section}; +use super::palette::{self, Command, Palette}; use super::task::{Bus, Event, Pending, Resource, Toast, ToastKind}; use super::text::truncate; use super::ui::{self, list::Click}; @@ -45,6 +48,7 @@ const SPEED_STEP: f32 = 0.1; /// How long a volume or speed change shows in the now-playing bar. const FLASH_FOR: Duration = Duration::from_millis(1500); const MAX_TOASTS: usize = 4; +const MAX_RECENT_SEARCHES: usize = 8; pub struct App { api: Api, @@ -77,6 +81,9 @@ pub struct App { help: Option, picker: Option, collections: Option, + palette: Option, + /// Newest first. + searches: Vec, /// The last remote-control notice already shown, so each shows once. notice_seen: Option, quit: bool, @@ -149,6 +156,8 @@ impl App { help: None, picker: None, collections: None, + palette: None, + searches: Vec::new(), notice_seen: None, quit: false, sidebar_rows: Vec::new(), @@ -223,6 +232,12 @@ impl App { page.changed(resource); } } + Event::Searched(search) => { + self.searches + .retain(|s| *s != search && !s.leads_to(&search)); + self.searches.insert(0, search); + self.searches.truncate(MAX_RECENT_SEARCHES); + } } } let now = Instant::now(); @@ -239,6 +254,9 @@ impl App { if let Some(picker) = &mut self.collections { picker.tick(&self.bus); } + if let Some(palette) = &mut self.palette { + palette.tick(&self.bus); + } self.show_remote_notice(); self.apply_server_theme(); self.apply_custom_theme(); @@ -413,6 +431,11 @@ impl App { self.collections_key(key); return; } + if let Some(palette) = &mut self.palette { + let outcome = palette.key(key); + self.palette_outcome(outcome); + return; + } if let Some(outcome) = self.with_page(|page, cx| page.key(key, cx)) { self.apply_outcome(outcome); @@ -423,14 +446,8 @@ impl App { } let contexts = self.page().contexts(); - let action = self.keymap.action(key.into(), contexts); - if let Some(action) = action { - let outcome = self.with_page(|page, cx| page.action(action, cx)); - if !matches!(outcome, Outcome::Ignored) { - self.apply_outcome(outcome); - return; - } - self.global(action); + if let Some(action) = self.keymap.action(key.into(), contexts) { + self.run_action(action); return; } // Digits go to sidebar entries in the order shown; 0 is the tenth. @@ -444,8 +461,22 @@ impl App { } } + /// What a key bound to `action` does: the page's meaning first, then the global one. + fn run_action(&mut self, action: Action) { + let outcome = self.with_page(|page, cx| page.action(action, cx)); + if matches!(outcome, Outcome::Ignored) { + self.global(action); + } else { + self.apply_outcome(outcome); + } + } + /// Text pasted into the terminal goes to whatever field has focus. pub fn paste(&mut self, text: &str) { + if let Some(palette) = &mut self.palette { + palette.paste(text); + return; + } if !self.page().typing() { return; } @@ -493,6 +524,10 @@ impl App { self.open_picker(); Ok(()) } + Action::Palette => { + self.open_palette(); + Ok(()) + } Action::AddToCollection => { match self.page().selected_episode() { Some(episode) => { @@ -555,6 +590,73 @@ impl App { }; } + fn open_palette(&mut self) { + // The open page's episodes come first, then the rest of what's loaded. + let current = Section::ALL + .iter() + .position(|s| *s == self.current) + .unwrap_or(0); + let mut stacks: Vec<&Vec>> = vec![&self.stacks[current]]; + stacks.extend( + self.stacks + .iter() + .enumerate() + .filter(|(i, _)| *i != current) + .map(|(_, stack)| stack), + ); + let pages = || stacks.iter().flat_map(|stack| stack.iter().rev()); + let entries = palette::entries( + &self.keymap, + palette::Sources { + contexts: self.page().contexts(), + sections: &self.order, + searches: &self.searches, + podcasts: pages().flat_map(|p| p.podcasts()).collect(), + episodes: pages().flat_map(|p| p.episodes()).collect(), + }, + ); + // Home's top podcasts are only a few; the Podcasts page or Search has them all. + let have_podcasts = [Section::Podcasts, Section::Search] + .iter() + .any(|s| !self.stack(*s)[0].podcasts().is_empty()); + let api = (!have_podcasts).then_some(&self.api); + self.palette = Some(Palette::new(entries, api)); + } + + fn palette_outcome(&mut self, outcome: palette::Outcome) { + match outcome { + palette::Outcome::Open => {} + palette::Outcome::Close => self.palette = None, + palette::Outcome::Run(command) => { + self.palette = None; + self.run_command(*command); + } + } + } + + fn run_command(&mut self, command: Command) { + match command { + Command::Action(action) => self.run_action(action), + Command::Go(section) => self.switch_to(section), + Command::Search(search) => { + *self.stack_mut(Section::Search) = vec![Box::new(SearchPage::repeat(&search))]; + if self.current == Section::Search { + self.with_page(|page, cx| page.show(cx)); + } else { + self.switch_to(Section::Search); + } + } + Command::Podcast(podcast) => { + self.stack_mut(Section::Podcasts).truncate(1); + self.switch_to(Section::Podcasts); + self.apply_outcome(Outcome::Push(Box::new(EpisodeListPage::podcast(podcast)))); + } + Command::Play(episode) => { + self.with_page(|_, cx| ui::episodes::play(&episode, cx)); + } + } + } + fn open_picker(&mut self) { let Some(episode) = self.page().selected_episode() else { self.bus.info("Select an episode first"); @@ -642,6 +744,11 @@ impl App { } return; } + if let Some(palette) = &mut self.palette { + let outcome = palette.mouse(event, click); + self.palette_outcome(outcome); + return; + } if click { if let Some(&(_, section)) = self.sidebar_rows.iter().find(|(r, _)| r.contains(at)) { self.switch_to(section); @@ -720,6 +827,9 @@ impl App { if let Some(picker) = &mut self.collections { picker.render(frame, area, &self.theme); } + if let Some(palette) = &mut self.palette { + palette.render(frame, area, &self.theme); + } if self.help.is_some() { self.render_help(frame, area); } diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs index 1b2a5f7..cbe4674 100644 --- a/src/tui/keymap.rs +++ b/src/tui/keymap.rs @@ -89,6 +89,7 @@ pub enum Action { SpeedDown, NowPlaying, PlayOn, + Palette, // List Up, Down, @@ -139,6 +140,65 @@ pub enum Action { } impl Action { + /// Every action, for the command palette's tests. + #[cfg(test)] + pub const ALL: [Action; 54] = [ + Self::Quit, + Self::Help, + Self::NextPage, + Self::PrevPage, + Self::Refresh, + Self::TogglePlay, + Self::SeekForward, + Self::SeekBackward, + Self::VolumeUp, + Self::VolumeDown, + Self::SpeedUp, + Self::SpeedDown, + Self::NowPlaying, + Self::PlayOn, + Self::Palette, + Self::Up, + Self::Down, + Self::PageUp, + Self::PageDown, + Self::Top, + Self::Bottom, + Self::Activate, + Self::Open, + Self::Back, + Self::Filter, + Self::CycleFilter, + Self::Queue, + Self::PlayNext, + Self::Save, + Self::Download, + Self::MarkPlayed, + Self::AddToCollection, + Self::Unsubscribe, + Self::Subscribe, + Self::CycleView, + Self::MoveUp, + Self::MoveDown, + Self::MoveToTop, + Self::MoveToBottom, + Self::Remove, + Self::ClearQueue, + Self::Shuffle, + Self::Stop, + Self::ResetSpeed, + Self::TrimSilence, + Self::NextChapter, + Self::PrevChapter, + Self::PlayHere, + Self::SendHere, + Self::RemoteToggle, + Self::RemoteBack, + Self::RemoteForward, + Self::RemoteSlower, + Self::RemoteFaster, + ]; + pub fn description(self) -> &'static str { match self { Self::Quit => "Quit", @@ -155,6 +215,7 @@ impl Action { Self::SpeedDown => "Slower", Self::NowPlaying => "Now playing", Self::PlayOn => "Play on device", + Self::Palette => "Command palette", Self::Up => "Up", Self::Down => "Down", Self::PageUp => "Page up", @@ -213,6 +274,7 @@ const DEFAULTS: &[(Context, Action, &[&str])] = &[ (Context::Global, Action::SpeedDown, &["["]), (Context::Global, Action::NowPlaying, &["n"]), (Context::Global, Action::Back, &["esc", "backspace", "h"]), + (Context::Global, Action::Palette, &[":", "ctrl+p"]), (Context::List, Action::Up, &["k", "up"]), (Context::List, Action::Down, &["j", "down"]), (Context::List, Action::PageUp, &["pageup", "ctrl+u"]), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index cdb10a0..aaa0a4e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3,6 +3,7 @@ mod collection_picker; mod device_picker; pub mod keymap; pub mod pages; +mod palette; pub mod task; pub mod text; pub mod ui; diff --git a/src/tui/pages/discover.rs b/src/tui/pages/discover.rs index 4f3ad67..cb8374f 100644 --- a/src/tui/pages/discover.rs +++ b/src/tui/pages/discover.rs @@ -163,6 +163,11 @@ impl Discover { } /// Searches again next time, as with a changed query. + /// The podcasts you follow, if they've been loaded. + pub fn following(&self) -> Vec<&Podcast> { + self.following.data.iter().flatten().collect() + } + pub fn forget(&mut self) { self.searched = None; } diff --git a/src/tui/pages/episode.rs b/src/tui/pages/episode.rs index 2518dc5..d1bf2cd 100644 --- a/src/tui/pages/episode.rs +++ b/src/tui/pages/episode.rs @@ -215,4 +215,8 @@ impl Page for EpisodePage { fn selected_episode(&self) -> Option { Some(self.episode.clone()) } + + fn episodes(&self) -> Vec<&Episode> { + vec![&self.episode] + } } diff --git a/src/tui/pages/episodes.rs b/src/tui/pages/episodes.rs index 2064e59..ff987b2 100644 --- a/src/tui/pages/episodes.rs +++ b/src/tui/pages/episodes.rs @@ -484,4 +484,8 @@ impl Page for EpisodeListPage { .and_then(|i| self.shown.get(i)) .cloned() } + + fn episodes(&self) -> Vec<&Episode> { + self.data.data.iter().flatten().collect() + } } diff --git a/src/tui/pages/home.rs b/src/tui/pages/home.rs index d29b8ae..43cde1d 100644 --- a/src/tui/pages/home.rs +++ b/src/tui/pages/home.rs @@ -15,7 +15,7 @@ use ratatui::{ use super::episode::EpisodePage; use super::episodes::EpisodeListPage; use super::{Ctx, Outcome, Page}; -use crate::api::{Episode, HomeOverview}; +use crate::api::{Episode, HomeOverview, Podcast}; use crate::tui::keymap::{Action, Context}; use crate::tui::task::{Remote, Resource}; use crate::tui::text::truncate; @@ -338,4 +338,22 @@ impl Page for HomePage { let index = self.lists[self.focus].selected_index()?; self.part_episodes(part).get(index).cloned() } + + fn episodes(&self) -> Vec<&Episode> { + let Some(home) = &self.data.data else { + return Vec::new(); + }; + home.in_progress_episodes + .iter() + .chain(&home.recent_episodes) + .collect() + } + + fn podcasts(&self) -> Vec<&Podcast> { + self.data + .data + .iter() + .flat_map(|home| &home.top_podcasts) + .collect() + } } diff --git a/src/tui/pages/mod.rs b/src/tui/pages/mod.rs index 3182cf2..7cd42fd 100644 --- a/src/tui/pages/mod.rs +++ b/src/tui/pages/mod.rs @@ -23,7 +23,7 @@ use ratatui::{ use super::keymap::{Action, Context, Keymap}; use super::task::{Bus, Resource}; -use crate::api::{Api, Episode}; +use crate::api::{Api, Episode, Podcast}; use crate::nowplaying::NowPlaying; use crate::playback::{Playback, PlayerState}; use crate::settings::SettingsManager; @@ -107,6 +107,16 @@ pub trait Page { fn selected_episode(&self) -> Option { None } + + /// Episodes the page has loaded, for the command palette. + fn episodes(&self) -> Vec<&Episode> { + Vec::new() + } + + /// Podcasts the page has loaded, for the command palette. + fn podcasts(&self) -> Vec<&Podcast> { + Vec::new() + } } /// A sidebar entry. diff --git a/src/tui/pages/podcasts.rs b/src/tui/pages/podcasts.rs index 89f2f0a..8ecbeaf 100644 --- a/src/tui/pages/podcasts.rs +++ b/src/tui/pages/podcasts.rs @@ -229,4 +229,8 @@ impl Page for PodcastsPage { cells }); } + + fn podcasts(&self) -> Vec<&Podcast> { + self.items().iter().collect() + } } diff --git a/src/tui/pages/queue.rs b/src/tui/pages/queue.rs index 0d75f11..003c22e 100644 --- a/src/tui/pages/queue.rs +++ b/src/tui/pages/queue.rs @@ -341,6 +341,10 @@ impl Page for QueuePage { .and_then(|i| self.items().get(i)) .map(|q| q.episode.clone()) } + + fn episodes(&self) -> Vec<&Episode> { + self.items().iter().map(|q| &q.episode).collect() + } } #[cfg(test)] diff --git a/src/tui/pages/saved.rs b/src/tui/pages/saved.rs index 565f672..0f08ee0 100644 --- a/src/tui/pages/saved.rs +++ b/src/tui/pages/saved.rs @@ -248,4 +248,8 @@ impl Page for SavedPage { self.saved.selected_episode() } } + + fn episodes(&self) -> Vec<&Episode> { + self.saved.episodes() + } } diff --git a/src/tui/pages/search.rs b/src/tui/pages/search.rs index 73a012e..9c02946 100644 --- a/src/tui/pages/search.rs +++ b/src/tui/pages/search.rs @@ -15,7 +15,7 @@ use unicode_width::UnicodeWidthStr; use super::discover::{self, Discover}; use super::episode::EpisodePage; use super::{Ctx, Outcome, Page}; -use crate::api::{Directory, Episode}; +use crate::api::{Directory, Episode, Podcast}; use crate::tui::keymap::{Action, Context}; use crate::tui::task::{Remote, Resource}; use crate::tui::ui::episodes::{self, Columns}; @@ -64,6 +64,28 @@ impl Mode { } } +/// A search that went out, for the command palette to offer again. +#[derive(Debug, Clone, PartialEq)] +pub struct RecentSearch { + mode: Mode, + pub query: String, +} + +impl RecentSearch { + pub fn label(&self) -> String { + match self.mode { + Mode::Episodes => format!("Search for {}", self.query), + mode => format!("Search {} for {}", mode.title(), self.query), + } + } + + /// Whether `self` was a step on the way to typing `later`, so only the + /// finished query is kept. + pub fn leads_to(&self, later: &RecentSearch) -> bool { + self.mode == later.mode && later.query.starts_with(&self.query) + } +} + pub struct SearchPage { mode: Mode, query: String, @@ -75,6 +97,8 @@ pub struct SearchPage { results: Remote>, list: ListView, discover: Discover, + /// Search as soon as the page shows; set when it's opened to repeat a search. + search_on_show: bool, } impl Default for SearchPage { @@ -88,21 +112,37 @@ impl Default for SearchPage { results: Remote::default(), list: ListView::default(), discover: Discover::default(), + search_on_show: false, } } } impl SearchPage { + /// The page with `recent` searched for again. + pub fn repeat(recent: &RecentSearch) -> Self { + Self { + mode: recent.mode, + query: recent.query.clone(), + editing: false, + search_on_show: true, + ..Self::default() + } + } + fn search(&mut self, cx: &Ctx) { self.edited = None; + let term = self.query.trim().to_string(); if let Mode::Find(source) = self.mode { + if !term.is_empty() { + self.remember(term, cx); + } self.discover.search(source, &self.query, cx); return; } - let term = self.query.trim().to_string(); if term.chars().count() < MIN_QUERY || term == self.searched && !self.results.needs_load() { return; } + self.remember(term.clone(), cx); self.searched = term.clone(); let api = cx.api.clone(); self.results.load(async move { @@ -119,6 +159,13 @@ impl SearchPage { }); } + fn remember(&self, query: String, cx: &Ctx) { + cx.bus.searched(RecentSearch { + mode: self.mode, + query, + }); + } + fn items(&self) -> &[Episode] { self.results.data.as_deref().unwrap_or_default() } @@ -194,6 +241,9 @@ impl Page for SearchPage { if let Mode::Find(_) = self.mode { self.discover.show(cx); } + if std::mem::take(&mut self.search_on_show) { + self.search(cx); + } } fn tick(&mut self, cx: &Ctx) { @@ -427,4 +477,12 @@ impl Page for SearchPage { .and_then(|i| self.items().get(i)) .cloned() } + + fn episodes(&self) -> Vec<&Episode> { + self.items().iter().collect() + } + + fn podcasts(&self) -> Vec<&Podcast> { + self.discover.following() + } } diff --git a/src/tui/palette.rs b/src/tui/palette.rs new file mode 100644 index 0000000..bc7c2a3 --- /dev/null +++ b/src/tui/palette.rs @@ -0,0 +1,636 @@ +//! The command palette (`:` or Ctrl+P): one fuzzy-matched list of what the page's +//! keys do, the sidebar entries, your podcasts, episodes already on screen somewhere +//! and recent searches. The app builds the entries; this keeps the query, ranks +//! the matches and draws them. + +use std::collections::HashSet; + +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use ratatui::{ + Frame, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}, + layout::{Constraint, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{Cell, Paragraph}, +}; +use unicode_width::UnicodeWidthStr; + +use super::keymap::{Action, Context, Keymap}; +use super::pages::Section; +use super::pages::search::RecentSearch; +use super::task::{Bus, Pending}; +use super::text::truncate; +use super::ui::{self, list::Click, list::ListView}; +use crate::api::{Api, ApiResult, Episode, Podcast}; +use crate::theme::Theme; + +const WIDTH: u16 = 84; +const MAX_ROWS: u16 = 14; +/// Past this many matches the rest aren't worth drawing. +const MAX_RESULTS: usize = 200; + +/// What an entry does when run. +#[derive(Debug, Clone)] +pub enum Command { + Action(Action), + Go(Section), + Search(RecentSearch), + Podcast(Podcast), + Play(Episode), +} + +impl Command { + /// Breaks ties between equally good matches, and orders the list before + /// anything is typed. + fn rank(&self) -> u8 { + match self { + // Moving the cursor is what keys are for; it's here to be complete. + Self::Action( + Action::Up + | Action::Down + | Action::PageUp + | Action::PageDown + | Action::Top + | Action::Bottom, + ) => 1, + Self::Action(_) => 0, + Self::Go(_) => 2, + Self::Search(_) => 3, + Self::Podcast(_) => 4, + Self::Play(_) => 5, + } + } +} + +#[derive(Debug, Clone)] +pub struct Entry { + /// What's matched and shown. + pub label: String, + /// Shown faintly on the right: a key, or where the entry comes from. + pub detail: String, + pub command: Command, +} + +/// What the palette offers, gathered from the app when it opens. +#[derive(Default)] +pub struct Sources<'a> { + /// The open page's contexts, most specific first. + pub contexts: &'a [Context], + /// Sidebar entries in the order shown. + pub sections: &'a [Section], + pub searches: &'a [RecentSearch], + pub podcasts: Vec<&'a Podcast>, + pub episodes: Vec<&'a Episode>, +} + +/// Every action that does something on a page with `contexts`, with the key that +/// does it there, if any. +pub fn actions(keymap: &Keymap, contexts: &[Context]) -> Vec<(Action, Option)> { + let mut seen = HashSet::new(); + contexts + .iter() + .chain(std::iter::once(&Context::Global)) + .flat_map(|c| keymap.bindings(*c)) + .filter(|(action, _)| *action != Action::Palette && seen.insert(*action)) + .map(|(action, _)| { + let key = keymap.key_for(*action, contexts).map(|k| k.to_string()); + (*action, key) + }) + .collect() +} + +pub fn entries(keymap: &Keymap, sources: Sources) -> Vec { + let mut out: Vec = actions(keymap, sources.contexts) + .into_iter() + .map(|(action, key)| Entry { + label: action.description().to_string(), + detail: key.unwrap_or_default(), + command: Command::Action(action), + }) + .collect(); + for (i, section) in sources.sections.iter().enumerate() { + let digit = match i { + 0..=8 => (i + 1).to_string(), + 9 => "0".into(), + _ => String::new(), + }; + out.push(Entry { + label: format!("Go to {}", section.title()), + detail: digit, + command: Command::Go(*section), + }); + } + for search in sources.searches { + out.push(Entry { + label: search.label(), + detail: "recent search".into(), + command: Command::Search(search.clone()), + }); + } + let mut seen = HashSet::new(); + for podcast in sources.podcasts { + if seen.insert(podcast.podcastid) { + out.push(podcast_entry(podcast)); + } + } + let mut seen = HashSet::new(); + for episode in sources.episodes { + if seen.insert(episode.key()) { + out.push(Entry { + label: format!("Play {}", episode.episode_title), + detail: episode.podcast_name.clone().unwrap_or_default(), + command: Command::Play(episode.clone()), + }); + } + } + out +} + +fn podcast_entry(podcast: &Podcast) -> Entry { + Entry { + label: podcast.podcastname.clone(), + detail: "podcast".into(), + command: Command::Podcast(podcast.clone()), + } +} + +/// A match: the entry's index and which characters of its label matched. +#[derive(Debug, Clone, PartialEq)] +struct Hit { + index: usize, + chars: Vec, +} + +/// Ranks `entries` against `query`: best match first, ties broken by kind and then +/// by the order they came in. An empty query keeps them all, in that order. +/// +/// The scoring is nucleo's (as in Helix): the query's characters must appear in +/// order, with bonuses for landing on word starts and for running together, and +/// each space-separated word is matched on its own. +fn rank(matcher: &mut Matcher, entries: &[Entry], query: &str) -> Vec { + let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); + let mut buf = Vec::new(); + let mut scored: Vec<(u32, Hit)> = entries + .iter() + .enumerate() + .filter_map(|(index, entry)| { + let haystack = Utf32Str::new(&entry.label, &mut buf); + let mut chars = Vec::new(); + let score = pattern.indices(haystack, matcher, &mut chars)?; + chars.sort_unstable(); + chars.dedup(); + Some((score, Hit { index, chars })) + }) + .collect(); + scored.sort_by(|(a_score, a), (b_score, b)| { + b_score + .cmp(a_score) + .then_with(|| { + entries[a.index] + .command + .rank() + .cmp(&entries[b.index].command.rank()) + }) + .then_with(|| a.index.cmp(&b.index)) + }); + scored + .into_iter() + .take(MAX_RESULTS) + .map(|(_, hit)| hit) + .collect() +} + +/// What a key in the palette led to. +pub enum Outcome { + Open, + Close, + Run(Box), +} + +pub struct Palette { + query: String, + entries: Vec, + hits: Vec, + list: ListView, + matcher: Matcher, + /// Your podcasts, when no page had them loaded yet. + podcasts: Pending>>, + /// Where the popup was drawn, so a click on it doesn't count as outside. + area: Rect, +} + +impl Palette { + /// `load_podcasts` fetches your podcasts in the background, for when no page has + /// them; they join the list when they arrive. + pub fn new(entries: Vec, load_podcasts: Option<&Api>) -> Self { + let podcasts = match load_podcasts { + Some(api) => { + let api = api.clone(); + Pending::spawn(async move { api.get_podcasts().await }) + } + None => Pending::default(), + }; + let mut palette = Self { + query: String::new(), + entries, + hits: Vec::new(), + list: ListView::default(), + matcher: Matcher::new(Config::DEFAULT), + podcasts, + area: Rect::default(), + }; + palette.rematch(); + palette + } + + pub fn tick(&mut self, bus: &Bus) { + match self.podcasts.poll() { + Some(Ok(podcasts)) => { + let known: HashSet = self + .entries + .iter() + .filter_map(|e| match &e.command { + Command::Podcast(p) => Some(p.podcastid), + _ => None, + }) + .collect(); + // Keep podcasts ahead of episodes, so the order without a query holds. + let at = self + .entries + .iter() + .position(|e| matches!(e.command, Command::Play(_))) + .unwrap_or(self.entries.len()); + let new: Vec = podcasts + .iter() + .filter(|p| !known.contains(&p.podcastid)) + .map(podcast_entry) + .collect(); + self.entries.splice(at..at, new); + let selected = self.selected().map(|e| e.label.clone()); + self.rematch(); + if let Some(label) = selected + && let Some(i) = self + .hits + .iter() + .position(|h| self.entries[h.index].label == label) + { + self.list.select_index(i); + } + } + Some(Err(e)) => { + tracing::warn!("couldn't load podcasts for the palette: {e}"); + bus.error(format!("Couldn't load your podcasts: {e}")); + } + None => {} + } + } + + fn rematch(&mut self) { + self.hits = rank(&mut self.matcher, &self.entries, &self.query); + self.list.sync(&self.hits, |_| String::new()); + self.list.navigate(Action::Top); + } + + fn selected(&self) -> Option<&Entry> { + let hit = self.hits.get(self.list.selected_index()?)?; + self.entries.get(hit.index) + } + + fn run_selected(&self) -> Outcome { + match self.selected() { + Some(entry) => Outcome::Run(Box::new(entry.command.clone())), + None => Outcome::Open, + } + } + + /// The palette has the keyboard while it's open: typing edits the query, and + /// movement keys that aren't characters move the selection. + pub fn key(&mut self, key: KeyEvent) -> Outcome { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => return Outcome::Close, + KeyCode::Enter => return self.run_selected(), + KeyCode::Up => self.list.move_by(-1), + KeyCode::Down | KeyCode::Tab => self.list.move_by(1), + KeyCode::BackTab => self.list.move_by(-1), + KeyCode::Char('p' | 'k') if ctrl => self.list.move_by(-1), + KeyCode::Char('n' | 'j') if ctrl => self.list.move_by(1), + KeyCode::PageUp => { + self.list.navigate(Action::PageUp); + } + KeyCode::PageDown => { + self.list.navigate(Action::PageDown); + } + KeyCode::Char('u') if ctrl => { + self.query.clear(); + self.rematch(); + } + KeyCode::Backspace => { + // Backspace on an empty query closes, as in most palettes. + if self.query.pop().is_none() { + return Outcome::Close; + } + self.rematch(); + } + KeyCode::Char(c) if !ctrl => { + self.query.push(c); + self.rematch(); + } + _ => {} + } + Outcome::Open + } + + pub fn paste(&mut self, text: &str) { + self.query.extend(text.chars().filter(|c| !c.is_control())); + self.rematch(); + } + + /// A click outside closes; clicking the selected row runs it. + pub fn mouse(&mut self, event: MouseEvent, click: bool) -> Outcome { + let inside = self + .area + .contains(ratatui::layout::Position::new(event.column, event.row)); + match self.list.mouse(event) { + Click::Activated => self.run_selected(), + Click::Missed if click && !inside => Outcome::Close, + _ => Outcome::Open, + } + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + let rows = (self.hits.len() as u16).clamp(1, MAX_ROWS); + let height = rows + 4; + let width = WIDTH.min(area.width.saturating_sub(4)); + // High on the screen, so the list grows down from where you're looking. + let rect = Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + (area.height.saturating_sub(height) / 4).min(4), + width, + height: height.min(area.height), + }; + self.area = rect; + let inner = ui::popup(frame, rect, "Commands", theme); + let inner = Rect { + x: inner.x + 1, + width: inner.width.saturating_sub(2), + ..inner + }; + let [input, _, body, _] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(0), + ]) + .areas(inner); + + let prompt = Span::styled("› ", theme.accent()); + let typed = if self.query.is_empty() { + Span::styled("Type a command, page, podcast or episode…", theme.faint()) + } else { + Span::styled(self.query.clone(), theme.base()) + }; + let count = if self.query.is_empty() { + Span::default() + } else { + Span::styled(self.hits.len().to_string(), theme.faint()) + }; + let [text, counter] = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(count.content.width() as u16), + ]) + .areas(input); + frame.render_widget(Paragraph::new(Line::from(vec![prompt, typed])), text); + frame.render_widget(Paragraph::new(count), counter); + let cursor = text.x + 2 + self.query.width() as u16; + frame.set_cursor_position((cursor.min(text.right().saturating_sub(1)), text.y)); + + if self.hits.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled("Nothing matches.", theme.muted())), + body, + ); + return; + } + let detail_width = self + .hits + .iter() + .map(|h| self.entries[h.index].detail.width()) + .max() + .unwrap_or(0) + .clamp(4, 28) as u16; + let (entries, hits) = (&self.entries, &self.hits); + let highlight = theme.accent().add_modifier(Modifier::BOLD); + self.list.render( + frame, + body, + theme, + &[Constraint::Fill(1), Constraint::Length(detail_width)], + None, + |i, w| { + let hit = &hits[i]; + let entry = &entries[hit.index]; + let style = match entry.command { + Command::Action(_) => theme.key(), + _ => theme.faint(), + }; + vec![ + Cell::from(highlighted( + &entry.label, + &hit.chars, + usize::from(w[0]), + highlight, + )), + Cell::from( + Line::styled(truncate(&entry.detail, usize::from(w[1])), style) + .right_aligned(), + ), + ] + }, + ); + } +} + +/// `text` cut to `width` columns, with the characters at `chars` (indexes into +/// its chars, sorted) in `style`. +fn highlighted( + text: &str, + chars: &[u32], + width: usize, + style: ratatui::style::Style, +) -> Line<'static> { + let text = truncate(text, width); + let mut spans: Vec = Vec::new(); + let mut run = String::new(); + let mut run_hit = false; + let mut next = chars.iter().peekable(); + for (i, c) in text.chars().enumerate() { + let hit = next.peek().is_some_and(|&&n| n as usize == i); + if hit { + next.next(); + } + if hit != run_hit && !run.is_empty() { + let done = std::mem::take(&mut run); + spans.push(if run_hit { + Span::styled(done, style) + } else { + Span::raw(done) + }); + } + run_hit = hit; + run.push(c); + } + if !run.is_empty() { + spans.push(if run_hit { + Span::styled(run, style) + } else { + Span::raw(run) + }); + } + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(label: &str, command: Command) -> Entry { + Entry { + label: label.into(), + detail: String::new(), + command, + } + } + + fn ranked(labels: &[&str], query: &str) -> Vec { + let entries: Vec = labels + .iter() + .map(|l| entry(l, Command::Action(Action::Quit))) + .collect(); + let mut matcher = Matcher::new(Config::DEFAULT); + rank(&mut matcher, &entries, query) + .into_iter() + .map(|h| entries[h.index].label.clone()) + .collect() + } + + #[test] + fn matches_need_every_character_in_order() { + let found = ranked(&["Add to queue", "Clear queue", "Quit"], "adq"); + assert_eq!(found, ["Add to queue"]); + assert!(ranked(&["Quit"], "tiuq").is_empty()); + } + + #[test] + fn word_starts_and_runs_beat_scattered_letters() { + // "pn" lands on the starts of "Play next"; in "Open in a new tab" the + // letters are scattered mid-word. + let found = ranked(&["Open in a new tab", "Play next"], "pn"); + assert_eq!(found[0], "Play next"); + // A contiguous run beats the same letters spread out. + let found = ranked(&["Search history", "Shuffle"], "shuf"); + assert_eq!(found[0], "Shuffle"); + } + + #[test] + fn words_match_in_any_order_and_case_is_smart() { + assert_eq!(ranked(&["Go to Queue"], "queue go"), ["Go to Queue"]); + assert_eq!(ranked(&["Go to Queue"], "QUEUE"), Vec::::new()); + assert_eq!(ranked(&["Go to Queue"], "Queue"), ["Go to Queue"]); + } + + #[test] + fn an_empty_query_keeps_everything_with_actions_first() { + let entries = vec![ + entry("Go to Home", Command::Go(Section::Home)), + entry("Quit", Command::Action(Action::Quit)), + ]; + let mut matcher = Matcher::new(Config::DEFAULT); + let hits = rank(&mut matcher, &entries, ""); + let order: Vec = hits.iter().map(|h| h.index).collect(); + assert_eq!(order, [1, 0]); + } + + #[test] + fn matched_characters_are_reported_for_highlighting() { + let entries = vec![entry("Play next", Command::Action(Action::PlayNext))]; + let mut matcher = Matcher::new(Config::DEFAULT); + let hits = rank(&mut matcher, &entries, "pn"); + assert_eq!(hits[0].chars, [0, 5]); + let line = highlighted("Play next", &hits[0].chars, 40, Default::default()); + let pieces: Vec<&str> = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(pieces, ["P", "lay ", "n", "ext"]); + } + + /// Every action can be run from the palette on some page, except opening the + /// palette itself. + #[test] + fn every_action_is_reachable() { + let keymap = Keymap::default(); + let mut reachable = HashSet::new(); + for context in Context::ALL { + for (action, _) in actions(&keymap, &[context]) { + reachable.insert(action); + } + } + for action in Action::ALL { + if action == Action::Palette { + assert!(!reachable.contains(&action)); + } else { + assert!(reachable.contains(&action), "{action:?} isn't offered"); + } + } + } + + #[test] + fn actions_show_the_key_that_works_on_the_page() { + let keymap = Keymap::default(); + let on_devices = actions(&keymap, &[Context::Devices]); + let send = on_devices + .iter() + .find(|(a, _)| *a == Action::SendHere) + .unwrap(); + assert_eq!(send.1.as_deref(), Some("s")); + // Saving isn't something the Devices page does. + assert!(on_devices.iter().all(|(a, _)| *a != Action::Save)); + let in_lists = actions(&keymap, &[Context::Episode, Context::List]); + let open = in_lists.iter().find(|(a, _)| *a == Action::Open).unwrap(); + assert_eq!(open.1.as_deref(), Some("i")); + } + + #[test] + fn duplicates_are_listed_once() { + let podcast = Podcast { + podcastid: 1, + podcastname: "Show".into(), + ..Default::default() + }; + let episode = Episode { + episode_id: 7, + episode_title: "Pilot".into(), + ..Default::default() + }; + let list = entries( + &Keymap::default(), + Sources { + podcasts: vec![&podcast, &podcast], + episodes: vec![&episode, &episode], + ..Default::default() + }, + ); + assert_eq!( + list.iter() + .filter(|e| matches!(e.command, Command::Podcast(_))) + .count(), + 1 + ); + assert_eq!( + list.iter() + .filter(|e| matches!(e.command, Command::Play(_))) + .count(), + 1 + ); + } +} diff --git a/src/tui/task.rs b/src/tui/task.rs index cc4f36d..d083ba0 100644 --- a/src/tui/task.rs +++ b/src/tui/task.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; +use super::pages::search::RecentSearch; use crate::api::ApiResult; /// A request in flight. @@ -178,6 +179,7 @@ pub struct Toast { pub enum Event { Toast(Toast), Changed(Resource), + Searched(RecentSearch), } /// The pages' way to tell the app things. Cheap to clone into spawned tasks. @@ -212,6 +214,10 @@ impl Bus { self.toast(ToastKind::Error, text); } + pub fn searched(&self, search: RecentSearch) { + let _ = self.tx.send(Event::Searched(search)); + } + pub fn changed(&self, resource: Resource) { let _ = self.tx.send(Event::Changed(resource)); }