fcefcc02a0
Full Ratatui implementation of the harmony music library manager prototype: - 6 tab views (Library 3-pane, Wanted, Queue, History, Calendar, Settings) - Vim/evil-mode keybindings (hjkl, counts, gg/G, w/b/e, Ctrl-d/u, H/M/L, marks, operator-pending) - SPC leader key with which-key popup (Doom Emacs style) - Command mode (:q, :theme, :help) and / search filter - Help and quit confirmation modals - Toast notification system with auto-dismiss - Gruvbox dark theme throughout
1026 lines
35 KiB
Rust
1026 lines
35 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::{
|
|
layout::{Constraint, Layout, Rect},
|
|
style::Style,
|
|
widgets::{ListState, Paragraph},
|
|
Frame,
|
|
};
|
|
|
|
use crate::data::{
|
|
sample_artists, sample_calendar, sample_history, sample_queue, sample_wanted,
|
|
Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry,
|
|
};
|
|
use crate::input::{
|
|
build_leader_tree, LeaderAction, LeaderNode, LeaderResult, LeaderState, VimState,
|
|
};
|
|
use crate::theme;
|
|
use crate::ui::cmdline::{render_cmdline, CmdLineState};
|
|
use crate::ui::library::{render_library, LibraryFocus, LibraryState};
|
|
use crate::ui::modals::{render_help_modal, render_quit_modal, ModalKind};
|
|
use crate::ui::notifications::{NotifKind, NotificationManager};
|
|
use crate::ui::statusbar::{render_statusbar, StatusHint};
|
|
use crate::ui::topbar::render_topbar;
|
|
use crate::ui::views::{render_calendar, render_history, render_queue, render_settings, render_wanted};
|
|
use crate::ui::which_key::render_which_key;
|
|
|
|
const PAGE_SIZE: usize = 12;
|
|
const HALF_PAGE: usize = 6;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum Tab {
|
|
#[default]
|
|
Library,
|
|
Wanted,
|
|
Queue,
|
|
History,
|
|
Calendar,
|
|
Settings,
|
|
}
|
|
|
|
impl Tab {
|
|
pub const ALL: [Tab; 6] = [
|
|
Tab::Library,
|
|
Tab::Wanted,
|
|
Tab::Queue,
|
|
Tab::History,
|
|
Tab::Calendar,
|
|
Tab::Settings,
|
|
];
|
|
|
|
pub fn from_number(n: u8) -> Option<Tab> {
|
|
match n {
|
|
1 => Some(Tab::Library),
|
|
2 => Some(Tab::Wanted),
|
|
3 => Some(Tab::Queue),
|
|
4 => Some(Tab::History),
|
|
5 => Some(Tab::Calendar),
|
|
6 => Some(Tab::Settings),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn index(&self) -> usize {
|
|
match self {
|
|
Tab::Library => 0,
|
|
Tab::Wanted => 1,
|
|
Tab::Queue => 2,
|
|
Tab::History => 3,
|
|
Tab::Calendar => 4,
|
|
Tab::Settings => 5,
|
|
}
|
|
}
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Tab::Library => "Library",
|
|
Tab::Wanted => "Wanted",
|
|
Tab::Queue => "Queue",
|
|
Tab::History => "History",
|
|
Tab::Calendar => "Calendar",
|
|
Tab::Settings => "Settings",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum Mode {
|
|
#[default]
|
|
Normal,
|
|
Command,
|
|
Search,
|
|
}
|
|
|
|
pub struct App {
|
|
pub running: bool,
|
|
pub tab: Tab,
|
|
pub mode: Mode,
|
|
pub size: Rect,
|
|
pub library: LibraryState,
|
|
pub vim: VimState,
|
|
pub leader: LeaderState,
|
|
pub leader_tree: LeaderNode,
|
|
pub cmd_line: CmdLineState,
|
|
pub search_filter: String,
|
|
pub status_message: Option<String>,
|
|
pub marks: std::collections::HashMap<char, MarkPosition>,
|
|
pub last_position: Option<MarkPosition>,
|
|
filtered_artists: Vec<Artist>,
|
|
pub modal: Option<ModalKind>,
|
|
pub wanted: Vec<WantedEntry>,
|
|
pub wanted_state: ListState,
|
|
pub queue: Vec<QueueEntry>,
|
|
pub queue_state: ListState,
|
|
pub history: Vec<HistoryEntry>,
|
|
pub history_state: ListState,
|
|
pub calendar: Vec<CalendarEntry>,
|
|
pub notifications: NotificationManager,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MarkPosition {
|
|
pub tab: Tab,
|
|
pub focus: LibraryFocus,
|
|
pub artist_idx: usize,
|
|
pub album_idx: usize,
|
|
pub track_idx: usize,
|
|
}
|
|
|
|
impl Default for App {
|
|
fn default() -> Self {
|
|
let artists = sample_artists();
|
|
let wanted = sample_wanted();
|
|
let queue = sample_queue();
|
|
let history = sample_history();
|
|
let calendar = sample_calendar();
|
|
|
|
let mut wanted_state = ListState::default();
|
|
if !wanted.is_empty() {
|
|
wanted_state.select(Some(0));
|
|
}
|
|
|
|
let mut queue_state = ListState::default();
|
|
if !queue.is_empty() {
|
|
queue_state.select(Some(0));
|
|
}
|
|
|
|
let mut history_state = ListState::default();
|
|
if !history.is_empty() {
|
|
history_state.select(Some(0));
|
|
}
|
|
|
|
Self {
|
|
running: true,
|
|
tab: Tab::Library,
|
|
mode: Mode::Normal,
|
|
size: Rect::default(),
|
|
library: LibraryState::new(artists.clone()),
|
|
vim: VimState::new(),
|
|
leader: LeaderState::new(),
|
|
leader_tree: build_leader_tree(),
|
|
cmd_line: CmdLineState::new(),
|
|
search_filter: String::new(),
|
|
status_message: None,
|
|
marks: std::collections::HashMap::new(),
|
|
last_position: None,
|
|
filtered_artists: artists,
|
|
modal: None,
|
|
wanted,
|
|
wanted_state,
|
|
queue,
|
|
queue_state,
|
|
history,
|
|
history_state,
|
|
calendar,
|
|
notifications: NotificationManager::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn capture_position(&self) -> MarkPosition {
|
|
MarkPosition {
|
|
tab: self.tab,
|
|
focus: self.library.focus,
|
|
artist_idx: self.library.artist_state.selected().unwrap_or(0),
|
|
album_idx: self.library.album_state.selected().unwrap_or(0),
|
|
track_idx: self.library.track_state.selected().unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
fn restore_position(&mut self, pos: &MarkPosition) {
|
|
self.tab = pos.tab;
|
|
self.library.focus = pos.focus;
|
|
self.library.artist_state.select(Some(pos.artist_idx));
|
|
self.library.album_state.select(Some(pos.album_idx));
|
|
self.library.track_state.select(Some(pos.track_idx));
|
|
}
|
|
|
|
fn set_status(&mut self, msg: impl Into<String>) {
|
|
self.status_message = Some(msg.into());
|
|
}
|
|
|
|
pub fn draw(&mut self, frame: &mut Frame) {
|
|
self.size = frame.area();
|
|
let area = frame.area();
|
|
|
|
frame.render_widget(
|
|
Paragraph::new("").style(Style::default().bg(theme::BG0)),
|
|
area,
|
|
);
|
|
|
|
let show_cmdline = self.mode != Mode::Normal;
|
|
let cmdline_height = if show_cmdline { 1 } else { 0 };
|
|
|
|
let chunks = Layout::vertical([
|
|
Constraint::Length(1),
|
|
Constraint::Min(1),
|
|
Constraint::Length(cmdline_height),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(area);
|
|
|
|
let queue_count = sample_queue().len();
|
|
let wanted_count = sample_wanted().len();
|
|
|
|
render_topbar(
|
|
frame,
|
|
chunks[0],
|
|
self.tab,
|
|
self.mode,
|
|
queue_count,
|
|
wanted_count,
|
|
);
|
|
|
|
self.render_main_content(frame, chunks[1]);
|
|
|
|
if show_cmdline {
|
|
let hint = match self.mode {
|
|
Mode::Command => "try :help · :theme light · :wanted · :q",
|
|
Mode::Search => "filter artists by name",
|
|
Mode::Normal => "",
|
|
};
|
|
render_cmdline(frame, chunks[2], self.mode, &self.cmd_line.text, hint);
|
|
}
|
|
|
|
let hints = self.get_hints();
|
|
let position = self.get_position();
|
|
render_statusbar(
|
|
frame,
|
|
chunks[3],
|
|
self.mode,
|
|
&hints,
|
|
position,
|
|
queue_count,
|
|
wanted_count,
|
|
self.status_message.as_deref(),
|
|
);
|
|
|
|
if self.leader.active {
|
|
render_which_key(frame, area, &self.leader, &self.leader_tree);
|
|
}
|
|
|
|
if let Some(modal) = &self.modal {
|
|
match modal {
|
|
ModalKind::Help => render_help_modal(frame, area),
|
|
ModalKind::Quit => render_quit_modal(frame, area, self.queue.len()),
|
|
}
|
|
}
|
|
|
|
self.notifications.render(frame, area);
|
|
}
|
|
|
|
fn render_main_content(&mut self, frame: &mut Frame, area: Rect) {
|
|
match self.tab {
|
|
Tab::Library => {
|
|
render_library(frame, area, &mut self.library);
|
|
}
|
|
Tab::Wanted => {
|
|
render_wanted(frame, area, &self.wanted, &mut self.wanted_state);
|
|
}
|
|
Tab::Queue => {
|
|
render_queue(frame, area, &self.queue, &mut self.queue_state);
|
|
}
|
|
Tab::History => {
|
|
render_history(frame, area, &self.history, &mut self.history_state);
|
|
}
|
|
Tab::Calendar => {
|
|
render_calendar(frame, area, &self.calendar);
|
|
}
|
|
Tab::Settings => {
|
|
render_settings(frame, area);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_hints(&self) -> Vec<StatusHint> {
|
|
match self.tab {
|
|
Tab::Library => vec![
|
|
StatusHint { key: "j/k", action: "move" },
|
|
StatusHint { key: "h/l", action: "pane" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
StatusHint { key: "a", action: "add" },
|
|
StatusHint { key: "/", action: "filter" },
|
|
StatusHint { key: "?", action: "help" },
|
|
],
|
|
Tab::Wanted => vec![
|
|
StatusHint { key: "j/k", action: "move" },
|
|
StatusHint { key: "s", action: "search" },
|
|
StatusHint { key: "m", action: "unmonitor" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
],
|
|
Tab::Queue => vec![
|
|
StatusHint { key: "j/k", action: "move" },
|
|
StatusHint { key: "x", action: "remove" },
|
|
StatusHint { key: "p", action: "pause" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
],
|
|
Tab::History => vec![
|
|
StatusHint { key: "j/k", action: "move" },
|
|
StatusHint { key: "d", action: "clear" },
|
|
StatusHint { key: "r", action: "retry" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
],
|
|
Tab::Calendar => vec![
|
|
StatusHint { key: "h/l", action: "month" },
|
|
StatusHint { key: "Enter", action: "details" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
],
|
|
Tab::Settings => vec![
|
|
StatusHint { key: "Tab", action: "next" },
|
|
StatusHint { key: "Enter", action: "edit" },
|
|
StatusHint { key: ":w", action: "save" },
|
|
StatusHint { key: "SPC", action: "leader" },
|
|
],
|
|
}
|
|
}
|
|
|
|
fn get_position(&self) -> Option<(usize, usize)> {
|
|
match self.tab {
|
|
Tab::Library => {
|
|
let idx = self.library.selected_artist_index().unwrap_or(0) + 1;
|
|
let total = self.library.artist_count();
|
|
Some((idx, total))
|
|
}
|
|
Tab::Wanted => {
|
|
let idx = self.wanted_state.selected().unwrap_or(0) + 1;
|
|
Some((idx, self.wanted.len()))
|
|
}
|
|
Tab::Queue => {
|
|
let idx = self.queue_state.selected().unwrap_or(0) + 1;
|
|
Some((idx, self.queue.len()))
|
|
}
|
|
Tab::History => {
|
|
let idx = self.history_state.selected().unwrap_or(0) + 1;
|
|
Some((idx, self.history.len()))
|
|
}
|
|
Tab::Calendar | Tab::Settings => None,
|
|
}
|
|
}
|
|
|
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
|
self.status_message = None;
|
|
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
|
self.running = false;
|
|
return;
|
|
}
|
|
|
|
if self.modal.is_some() {
|
|
self.handle_modal_key(key);
|
|
return;
|
|
}
|
|
|
|
match self.mode {
|
|
Mode::Command | Mode::Search => {
|
|
self.handle_cmdline_key(key);
|
|
return;
|
|
}
|
|
Mode::Normal => {}
|
|
}
|
|
|
|
if self.leader.active {
|
|
self.handle_leader_key(key);
|
|
return;
|
|
}
|
|
|
|
self.handle_normal_key(key);
|
|
}
|
|
|
|
fn handle_modal_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Char('n') => {
|
|
self.modal = None;
|
|
}
|
|
KeyCode::Char('y') => {
|
|
if self.modal == Some(ModalKind::Quit) {
|
|
self.running = false;
|
|
}
|
|
}
|
|
KeyCode::Char('q') => {
|
|
self.modal = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_cmdline_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
self.mode = Mode::Normal;
|
|
self.cmd_line.clear();
|
|
}
|
|
KeyCode::Enter => {
|
|
let text = self.cmd_line.take();
|
|
if self.mode == Mode::Command {
|
|
self.execute_command(&text);
|
|
} else {
|
|
self.execute_search(&text);
|
|
}
|
|
self.mode = Mode::Normal;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
self.cmd_line.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
if self.cmd_line.pop().is_none() || self.cmd_line.is_empty() {
|
|
self.mode = Mode::Normal;
|
|
self.cmd_line.clear();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_leader_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
self.leader.deactivate();
|
|
}
|
|
KeyCode::Backspace => {
|
|
self.leader.go_back();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
let result = self.leader.navigate(c, &self.leader_tree);
|
|
match result {
|
|
LeaderResult::Pending => {}
|
|
LeaderResult::Executed(action) => {
|
|
self.execute_leader_action(action);
|
|
}
|
|
LeaderResult::NotBound => {
|
|
let path: String = self.leader.path.iter().collect();
|
|
self.set_status(format!("SPC {} {} not bound", path, c));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn execute_leader_action(&mut self, action: LeaderAction) {
|
|
match action {
|
|
LeaderAction::SetTab(tab) => self.tab = tab,
|
|
LeaderAction::SetMode(mode) => {
|
|
self.mode = mode;
|
|
self.cmd_line.clear();
|
|
}
|
|
LeaderAction::NextTab => {
|
|
let idx = (self.tab.index() + 1) % Tab::ALL.len();
|
|
self.tab = Tab::ALL[idx];
|
|
}
|
|
LeaderAction::PrevTab => {
|
|
let idx = (self.tab.index() + Tab::ALL.len() - 1) % Tab::ALL.len();
|
|
self.tab = Tab::ALL[idx];
|
|
}
|
|
LeaderAction::ToggleMonitor => {
|
|
self.set_status("monitor toggled");
|
|
self.notify("monitor toggled", None, NotifKind::Info, "◉");
|
|
}
|
|
LeaderAction::Refresh => {
|
|
self.set_status("scanning library...");
|
|
self.notify("scanning library", Some("18 artists".into()), NotifKind::Info, "↻");
|
|
}
|
|
LeaderAction::SearchRelease => {
|
|
self.set_status("search release");
|
|
}
|
|
LeaderAction::CycleTheme => {
|
|
self.set_status("theme cycled");
|
|
self.notify("theme", Some("dark".into()), NotifKind::Success, "◐");
|
|
}
|
|
LeaderAction::SetThemeDark => {
|
|
self.set_status("theme: dark");
|
|
self.notify("theme", Some("dark".into()), NotifKind::Success, "●");
|
|
}
|
|
LeaderAction::SetThemeLight => {
|
|
self.set_status("theme: light");
|
|
self.notify("theme", Some("light".into()), NotifKind::Success, "○");
|
|
}
|
|
LeaderAction::ShowNotifications => {
|
|
self.set_status("notifications");
|
|
}
|
|
LeaderAction::DismissNotifications => {
|
|
self.set_status("notifications dismissed");
|
|
}
|
|
LeaderAction::ShowHelp => {
|
|
self.modal = Some(ModalKind::Help);
|
|
}
|
|
LeaderAction::ShowAdd => {
|
|
self.set_status("add artist");
|
|
}
|
|
LeaderAction::ShowQuit => {
|
|
self.modal = Some(ModalKind::Quit);
|
|
}
|
|
LeaderAction::SyncLibrary => {
|
|
self.set_status("library synced · 0 changes");
|
|
self.notify("library synced", Some("0 changes".into()), NotifKind::Success, "✓");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_normal_key(&mut self, key: KeyEvent) {
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
|
|
if self.vim.has_pending() {
|
|
self.handle_pending_key(key);
|
|
return;
|
|
}
|
|
|
|
if let KeyCode::Char(c) = key.code {
|
|
if self.vim.accumulate_count(c) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let count = self.vim.take_count();
|
|
|
|
if ctrl {
|
|
self.handle_ctrl_key(key, count);
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char(' ') => {
|
|
self.leader.activate();
|
|
}
|
|
KeyCode::Char(':') => {
|
|
self.mode = Mode::Command;
|
|
self.cmd_line.clear();
|
|
}
|
|
KeyCode::Char('/') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.mode = Mode::Search;
|
|
self.cmd_line.clear();
|
|
}
|
|
KeyCode::Char('?') => {
|
|
self.modal = Some(ModalKind::Help);
|
|
}
|
|
|
|
KeyCode::Char('g') | KeyCode::Char('z') | KeyCode::Char('m') | KeyCode::Char('\'')
|
|
| KeyCode::Char('`') | KeyCode::Char('[') | KeyCode::Char(']') => {
|
|
if let KeyCode::Char(c) = key.code {
|
|
self.vim.set_pending(c);
|
|
}
|
|
}
|
|
|
|
KeyCode::Char('j') | KeyCode::Down => self.move_cursor(count as isize),
|
|
KeyCode::Char('k') | KeyCode::Up => self.move_cursor(-(count as isize)),
|
|
|
|
KeyCode::Char('h') | KeyCode::Left => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus_left();
|
|
}
|
|
}
|
|
KeyCode::Char('l') | KeyCode::Right => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus_right();
|
|
}
|
|
}
|
|
|
|
KeyCode::Char('w') => self.move_cursor(3 * count as isize),
|
|
KeyCode::Char('W') => self.move_cursor(5 * count as isize),
|
|
KeyCode::Char('b') => self.move_cursor(-3 * (count as isize)),
|
|
KeyCode::Char('B') => self.move_cursor(-5 * (count as isize)),
|
|
KeyCode::Char('e') => self.move_cursor(3 * count as isize),
|
|
KeyCode::Char('E') => self.move_cursor(5 * count as isize),
|
|
|
|
KeyCode::Char('{') => self.move_cursor(-5 * (count as isize)),
|
|
KeyCode::Char('}') => self.move_cursor(5 * count as isize),
|
|
KeyCode::Char('(') => self.move_cursor(-3 * (count as isize)),
|
|
KeyCode::Char(')') => self.move_cursor(3 * count as isize),
|
|
|
|
KeyCode::Char('0') | KeyCode::Char('^') => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus = LibraryFocus::Artists;
|
|
}
|
|
}
|
|
KeyCode::Char('$') => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus = LibraryFocus::Tracks;
|
|
}
|
|
}
|
|
|
|
KeyCode::Char('G') => {
|
|
self.last_position = Some(self.capture_position());
|
|
if self.vim.has_count() {
|
|
let line = self.vim.take_count();
|
|
self.move_to(line.saturating_sub(1));
|
|
} else {
|
|
self.move_to_end();
|
|
}
|
|
}
|
|
|
|
KeyCode::Char('H') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_to(0);
|
|
}
|
|
KeyCode::Char('M') => {
|
|
self.last_position = Some(self.capture_position());
|
|
let len = self.current_list_len();
|
|
self.move_to(len / 2);
|
|
}
|
|
KeyCode::Char('L') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_to_end();
|
|
}
|
|
|
|
KeyCode::Char('n') => {
|
|
self.set_status("n: repeat search forward");
|
|
}
|
|
KeyCode::Char('N') => {
|
|
self.set_status("N: repeat search backward");
|
|
}
|
|
KeyCode::Char('*') => {
|
|
self.set_status("*: search word under cursor forward");
|
|
}
|
|
KeyCode::Char('#') => {
|
|
self.set_status("#: search word under cursor backward");
|
|
}
|
|
|
|
KeyCode::Char('a') => {
|
|
self.set_status("add artist");
|
|
}
|
|
KeyCode::Char('s') => {
|
|
self.set_status("search release");
|
|
}
|
|
KeyCode::Char('r') => {
|
|
self.set_status("refresh");
|
|
}
|
|
KeyCode::Char('t') => {
|
|
self.set_status("monitor toggled");
|
|
}
|
|
|
|
KeyCode::Char('q') => {
|
|
self.modal = Some(ModalKind::Quit);
|
|
}
|
|
|
|
KeyCode::Char('1') => self.tab = Tab::Library,
|
|
KeyCode::Char('2') => self.tab = Tab::Wanted,
|
|
KeyCode::Char('3') => self.tab = Tab::Queue,
|
|
KeyCode::Char('4') => self.tab = Tab::History,
|
|
KeyCode::Char('5') => self.tab = Tab::Calendar,
|
|
KeyCode::Char('6') => self.tab = Tab::Settings,
|
|
|
|
KeyCode::Tab => {
|
|
if self.tab == Tab::Library {
|
|
self.library.cycle_focus();
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus_right();
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
self.vim.clear();
|
|
if !self.search_filter.is_empty() {
|
|
self.search_filter.clear();
|
|
self.update_filtered_artists();
|
|
self.set_status("filter cleared");
|
|
} else if self.library.focus != LibraryFocus::Artists {
|
|
self.library.focus_left();
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_ctrl_key(&mut self, key: KeyEvent, count: usize) {
|
|
match key.code {
|
|
KeyCode::Char('d') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_cursor((HALF_PAGE * count) as isize);
|
|
}
|
|
KeyCode::Char('u') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_cursor(-((HALF_PAGE * count) as isize));
|
|
}
|
|
KeyCode::Char('f') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_cursor((PAGE_SIZE * count) as isize);
|
|
}
|
|
KeyCode::Char('b') => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_cursor(-((PAGE_SIZE * count) as isize));
|
|
}
|
|
KeyCode::Char('e') => {
|
|
self.move_cursor(count as isize);
|
|
}
|
|
KeyCode::Char('y') => {
|
|
self.move_cursor(-(count as isize));
|
|
}
|
|
KeyCode::Char('o') => {
|
|
if let Some(pos) = self.last_position.take() {
|
|
let current = self.capture_position();
|
|
self.restore_position(&pos);
|
|
self.last_position = Some(current);
|
|
} else {
|
|
self.set_status("at start of jumplist");
|
|
}
|
|
}
|
|
KeyCode::Char('i') => {
|
|
self.set_status("Ctrl-i: forward in jumplist");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_pending_key(&mut self, key: KeyEvent) {
|
|
let pending = match self.vim.take_pending() {
|
|
Some(p) => p,
|
|
None => return,
|
|
};
|
|
let count = self.vim.take_count();
|
|
|
|
let KeyCode::Char(k) = key.code else {
|
|
return;
|
|
};
|
|
|
|
match pending {
|
|
'g' => match k {
|
|
'g' => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_to(0);
|
|
}
|
|
'e' => self.move_cursor(-3 * (count as isize)),
|
|
'E' => self.move_cursor(-5 * (count as isize)),
|
|
'j' => self.move_cursor(count as isize),
|
|
'k' => self.move_cursor(-(count as isize)),
|
|
'_' => self.move_to_end(),
|
|
'0' => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus = LibraryFocus::Artists;
|
|
}
|
|
}
|
|
'$' => {
|
|
if self.tab == Tab::Library {
|
|
self.library.focus = LibraryFocus::Tracks;
|
|
}
|
|
}
|
|
't' => {
|
|
let idx = (self.tab.index() + 1) % Tab::ALL.len();
|
|
self.tab = Tab::ALL[idx];
|
|
}
|
|
'T' => {
|
|
let idx = (self.tab.index() + Tab::ALL.len() - 1) % Tab::ALL.len();
|
|
self.tab = Tab::ALL[idx];
|
|
}
|
|
_ => {}
|
|
},
|
|
'z' => match k {
|
|
'z' | '.' => self.set_status("zz: cursor centered"),
|
|
't' => self.set_status("zt: cursor → viewport top"),
|
|
'b' | '-' => self.set_status("zb: cursor → viewport bottom"),
|
|
_ => {}
|
|
},
|
|
'm' => {
|
|
if k.is_ascii_alphabetic() {
|
|
let pos = self.capture_position();
|
|
self.marks.insert(k, pos);
|
|
self.set_status(format!("mark '{}' set", k));
|
|
self.notify("mark set", Some(format!("'{}", k)), NotifKind::Info, "⚑");
|
|
}
|
|
}
|
|
'\'' | '`' => {
|
|
if k == '\'' || k == '`' {
|
|
if let Some(pos) = self.last_position.take() {
|
|
let current = self.capture_position();
|
|
self.restore_position(&pos);
|
|
self.last_position = Some(current);
|
|
} else {
|
|
self.set_status("no previous position");
|
|
}
|
|
} else if k.is_ascii_alphabetic() {
|
|
if let Some(pos) = self.marks.get(&k).cloned() {
|
|
self.last_position = Some(self.capture_position());
|
|
self.restore_position(&pos);
|
|
} else {
|
|
self.set_status(format!("E20: mark not set: '{}'", k));
|
|
}
|
|
}
|
|
}
|
|
'[' => match k {
|
|
'[' => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_to(0);
|
|
}
|
|
'c' => self.move_cursor(-(count as isize)),
|
|
_ => {}
|
|
},
|
|
']' => match k {
|
|
']' => {
|
|
self.last_position = Some(self.capture_position());
|
|
self.move_to_end();
|
|
}
|
|
'c' => self.move_cursor(count as isize),
|
|
_ => {}
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn current_list_len(&self) -> usize {
|
|
match self.tab {
|
|
Tab::Library => match self.library.focus {
|
|
LibraryFocus::Artists => self.library.artist_count(),
|
|
LibraryFocus::Albums => self
|
|
.library
|
|
.selected_artist()
|
|
.map(|a| a.albums.len())
|
|
.unwrap_or(0),
|
|
LibraryFocus::Tracks => self.library.selected_album().map(|a| a.total as usize).unwrap_or(0),
|
|
},
|
|
Tab::Wanted => self.wanted.len(),
|
|
Tab::Queue => self.queue.len(),
|
|
Tab::History => self.history.len(),
|
|
Tab::Calendar | Tab::Settings => 0,
|
|
}
|
|
}
|
|
|
|
fn move_cursor(&mut self, delta: isize) {
|
|
let len = self.current_list_len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
|
|
match self.tab {
|
|
Tab::Library => match self.library.focus {
|
|
LibraryFocus::Artists => {
|
|
let current = self.library.artist_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
if new_idx != current {
|
|
self.library.artist_state.select(Some(new_idx));
|
|
self.library.album_state.select(Some(0));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
}
|
|
LibraryFocus::Albums => {
|
|
let current = self.library.album_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
if new_idx != current {
|
|
self.library.album_state.select(Some(new_idx));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
}
|
|
LibraryFocus::Tracks => {
|
|
let current = self.library.track_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
self.library.track_state.select(Some(new_idx));
|
|
}
|
|
},
|
|
Tab::Wanted => {
|
|
let current = self.wanted_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
self.wanted_state.select(Some(new_idx));
|
|
}
|
|
Tab::Queue => {
|
|
let current = self.queue_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
self.queue_state.select(Some(new_idx));
|
|
}
|
|
Tab::History => {
|
|
let current = self.history_state.selected().unwrap_or(0);
|
|
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
|
|
self.history_state.select(Some(new_idx));
|
|
}
|
|
Tab::Calendar | Tab::Settings => {}
|
|
}
|
|
}
|
|
|
|
fn move_to(&mut self, idx: usize) {
|
|
let len = self.current_list_len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
|
|
let new_idx = idx.min(len - 1);
|
|
|
|
match self.tab {
|
|
Tab::Library => match self.library.focus {
|
|
LibraryFocus::Artists => {
|
|
self.library.artist_state.select(Some(new_idx));
|
|
self.library.album_state.select(Some(0));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
LibraryFocus::Albums => {
|
|
self.library.album_state.select(Some(new_idx));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
LibraryFocus::Tracks => {
|
|
self.library.track_state.select(Some(new_idx));
|
|
}
|
|
},
|
|
Tab::Wanted => {
|
|
self.wanted_state.select(Some(new_idx));
|
|
}
|
|
Tab::Queue => {
|
|
self.queue_state.select(Some(new_idx));
|
|
}
|
|
Tab::History => {
|
|
self.history_state.select(Some(new_idx));
|
|
}
|
|
Tab::Calendar | Tab::Settings => {}
|
|
}
|
|
}
|
|
|
|
fn move_to_end(&mut self) {
|
|
let len = self.current_list_len();
|
|
if len > 0 {
|
|
self.move_to(len - 1);
|
|
}
|
|
}
|
|
|
|
fn execute_command(&mut self, text: &str) {
|
|
let text = text.trim();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let parts: Vec<&str> = text.split_whitespace().collect();
|
|
let cmd = parts.first().map(|s| s.to_lowercase()).unwrap_or_default();
|
|
|
|
match cmd.as_str() {
|
|
"q" | "quit" => self.running = false,
|
|
"w" | "write" | "sync" => {
|
|
self.set_status("library synced · 0 changes");
|
|
}
|
|
"wq" => {
|
|
self.set_status("synced · bye");
|
|
self.running = false;
|
|
}
|
|
"help" | "h" => {
|
|
self.set_status("help");
|
|
}
|
|
"theme" => {
|
|
if let Some(theme) = parts.get(1) {
|
|
match *theme {
|
|
"dark" => self.set_status("theme: dark"),
|
|
"light" => self.set_status("theme: light"),
|
|
_ => self.set_status("usage: :theme dark | light"),
|
|
}
|
|
} else {
|
|
self.set_status("usage: :theme dark | light");
|
|
}
|
|
}
|
|
"lib" | "library" => self.tab = Tab::Library,
|
|
"wanted" => self.tab = Tab::Wanted,
|
|
"queue" => self.tab = Tab::Queue,
|
|
"history" => self.tab = Tab::History,
|
|
"cal" | "calendar" => self.tab = Tab::Calendar,
|
|
"settings" | "set" => self.tab = Tab::Settings,
|
|
"refresh" | "rescan" | "r" => {
|
|
self.set_status("library refreshed");
|
|
}
|
|
_ => {
|
|
self.set_status(format!("E492: not an editor command: {}", text));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn execute_search(&mut self, text: &str) {
|
|
let filter = text.trim().to_string();
|
|
self.search_filter = filter.clone();
|
|
self.update_filtered_artists();
|
|
|
|
if filter.is_empty() {
|
|
self.set_status("filter cleared");
|
|
} else {
|
|
let count = self.filtered_artists.len();
|
|
self.set_status(format!("filter: /{} · {} matches", filter, count));
|
|
self.notify("filter", Some(format!("/{}", filter)), NotifKind::Info, "⌕");
|
|
}
|
|
}
|
|
|
|
fn update_filtered_artists(&mut self) {
|
|
if self.search_filter.is_empty() {
|
|
self.filtered_artists = self.library.artists.clone();
|
|
} else {
|
|
let query = self.search_filter.to_lowercase();
|
|
self.filtered_artists = self
|
|
.library
|
|
.artists
|
|
.iter()
|
|
.filter(|a| a.name.to_lowercase().contains(&query))
|
|
.cloned()
|
|
.collect();
|
|
}
|
|
self.library.artist_state.select(Some(0));
|
|
}
|
|
|
|
pub fn handle_tick(&mut self) {
|
|
if self.status_message.is_some() {
|
|
self.status_message = None;
|
|
}
|
|
self.notifications.tick();
|
|
}
|
|
|
|
fn notify(&mut self, title: impl Into<String>, detail: Option<String>, kind: NotifKind, icon: impl Into<String>) {
|
|
self.notifications.push(title, detail, kind, icon);
|
|
}
|
|
}
|