use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::app::{App, Mode, Pane, Tab};
use crate::excmd;
pub fn handle(app: &mut App, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
match app.mode {
Mode::Command | Mode::Search => line_mode(app, key),
Mode::Edit | Mode::EditInsert => edit_mode(app, key),
Mode::Normal | Mode::Visual => normal_mode(app, key),
}
}
fn line_mode(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => leave_line(app),
KeyCode::Char('c') if ctrl => leave_line(app),
KeyCode::Backspace => {
if app.line.is_empty() {
leave_line(app);
} else if app.line_cur > 0 {
let mut text = chars(app);
text.remove(app.line_cur - 1);
app.line_cur -= 1;
set_line(app, &text);
}
}
KeyCode::Delete => {
let mut text = chars(app);
if app.line_cur < text.len() {
text.remove(app.line_cur);
set_line(app, &text);
}
}
KeyCode::Left => app.line_cur = app.line_cur.saturating_sub(1),
KeyCode::Right => app.line_cur = (app.line_cur + 1).min(chars(app).len()),
KeyCode::Home => app.line_cur = 0,
KeyCode::End => app.line_cur = chars(app).len(),
KeyCode::Char('u') if ctrl => {
app.line.clear();
app.line_cur = 0;
}
KeyCode::Char('w') if ctrl => {
let mut text = chars(app);
let start = word_start(&text, app.line_cur);
text.drain(start..app.line_cur);
app.line_cur = start;
set_line(app, &text);
}
KeyCode::Enter => {
let line = std::mem::take(&mut app.line);
let was_search = app.mode == Mode::Search;
let backward = app.line_prefix == '?';
app.line_cur = 0;
app.mode = if app.edit.is_some() {
Mode::Edit
} else {
Mode::Normal
};
if was_search {
app.last_search = line.clone();
app.search_back = backward;
if !app.search(&line, backward, app.cur) {
app.error(format!("pattern not found: {line}"));
}
} else {
excmd::run(app, &line);
}
}
KeyCode::Char(c) => {
let mut text = chars(app);
text.insert(app.line_cur.min(text.len()), c);
app.line_cur += 1;
set_line(app, &text);
}
_ => {}
}
}
fn edit_mode(app: &mut App, key: KeyEvent) {
if app.mode == Mode::EditInsert {
edit_insert(app, key);
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if let Some(op) = app.pending.take() {
edit_operator(app, op, key);
return;
}
match key.code {
KeyCode::Esc => {
app.commit_name();
app.mode = Mode::Normal;
return;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.line_prefix = ':';
app.line.clear();
app.line_cur = 0;
return;
}
KeyCode::Char('u') => {
app.undo();
return;
}
KeyCode::Char('r') if ctrl => {
app.redo();
return;
}
KeyCode::Char('j') | KeyCode::Down if app.renaming_a_track() => {
app.edit_next_row(1);
return;
}
KeyCode::Char('k') | KeyCode::Up if app.renaming_a_track() => {
app.edit_next_row(-1);
return;
}
KeyCode::Char('c' | 'd') => {
app.pending = Some(if key.code == KeyCode::Char('c') { 'c' } else { 'd' });
return;
}
_ => {}
}
if matches!(
key.code,
KeyCode::Char('i' | 'a' | 'I' | 'A' | 'x' | 'D' | 'C' | 'S') | KeyCode::Delete
) {
app.checkpoint();
}
let insert = matches!(key.code, KeyCode::Char('i' | 'a' | 'I' | 'A' | 'C' | 'S'));
let Some(buf) = app.name_buffer() else { return };
match key.code {
KeyCode::Char('h') | KeyCode::Left => buf.left(),
KeyCode::Char('l') | KeyCode::Right => buf.right(),
KeyCode::Char('0') => buf.jump_start(),
KeyCode::Char('$') => buf.jump_end(),
KeyCode::Char('w') if !ctrl => buf.jump_word_forward(),
KeyCode::Char('b') => buf.jump_word_back(),
KeyCode::Char('e') => buf.jump_word_end(),
KeyCode::Char('i') => {}
KeyCode::Char('a') => buf.append_here(),
KeyCode::Char('I') => buf.jump_start(),
KeyCode::Char('A') => buf.append_at_end(),
KeyCode::Char('x') | KeyCode::Delete => buf.delete_here(),
KeyCode::Char('D' | 'C') => buf.truncate_here(),
KeyCode::Char('S') => buf.clear(),
_ => return,
}
if insert {
app.mode = Mode::EditInsert;
}
}
fn edit_operator(app: &mut App, op: char, key: KeyEvent) {
app.checkpoint();
let Some(buf) = app.name_buffer() else { return };
match (op, key.code) {
('c', KeyCode::Char('c')) | ('d', KeyCode::Char('d')) => buf.clear(),
(_, KeyCode::Char('w' | 'e')) => buf.delete_word(op == 'c' || key.code == KeyCode::Char('e')),
(_, KeyCode::Char('b')) => buf.delete_word_back(),
(_, KeyCode::Char('$')) => buf.truncate_here(),
_ => return,
}
if op == 'c' {
app.mode = Mode::EditInsert;
}
}
fn edit_insert(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
if let Some(buf) = app.name_buffer() {
buf.left();
}
app.mode = Mode::Edit;
return;
}
KeyCode::Enter => {
app.mode = Mode::Edit;
return;
}
_ => {}
}
let Some(buf) = app.name_buffer() else { return };
match key.code {
KeyCode::Backspace => buf.backspace(),
KeyCode::Delete => buf.delete_here(),
KeyCode::Left => buf.left(),
KeyCode::Right => buf.append_here(),
KeyCode::Home => buf.jump_start(),
KeyCode::End => buf.append_at_end(),
KeyCode::Char('u') if ctrl => buf.clear(),
KeyCode::Char('w') if ctrl => buf.delete_word_back(),
KeyCode::Char(c) => buf.insert(c),
_ => {}
}
}
fn chars(app: &App) -> Vec<char> {
app.line.chars().collect()
}
fn set_line(app: &mut App, chars: &[char]) {
app.line = chars.iter().collect();
app.line_cur = app.line_cur.min(app.line.chars().count());
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum Class {
Space,
Word,
Punct,
}
fn class(c: char) -> Class {
if c.is_whitespace() {
Class::Space
} else if c.is_alphanumeric() || c == '_' {
Class::Word
} else {
Class::Punct
}
}
fn word_start(text: &[char], at: usize) -> usize {
let mut i = at.min(text.len());
while i > 0 && class(text[i - 1]) == Class::Space {
i -= 1;
}
if i > 0 {
let kind = class(text[i - 1]);
while i > 0 && class(text[i - 1]) == kind {
i -= 1;
}
}
i
}
fn leave_line(app: &mut App) {
app.line.clear();
app.line_cur = 0;
app.mode = if app.edit.is_some() {
Mode::Edit
} else {
Mode::Normal
};
}
#[allow(clippy::too_many_lines)] fn normal_mode(app: &mut App, key: KeyEvent) {
if app.show_changes {
if matches!(
key.code,
KeyCode::Char('q' | 'Q') | KeyCode::Esc | KeyCode::Enter
) {
app.show_changes = false;
}
return;
}
if app.show_help {
help_key(app, key);
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let count = app.count.unwrap_or(1);
let has_count = app.count.is_some();
if let Some(prefix) = app.pending.take() {
pending_key(app, prefix, key, count, has_count);
return;
}
if let KeyCode::Char(c @ '0'..='9') = key.code
&& !ctrl
&& (c != '0' || app.count.is_some())
{
let digit = c.to_digit(10).unwrap_or(0) as usize;
app.count = Some(app.count.unwrap_or(0) * 10 + digit);
return;
}
let mut clear_count = true;
match key.code {
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.line_prefix = ':';
app.line.clear();
}
KeyCode::Char('/') | KeyCode::Char('?') => {
app.line_prefix = if key.code == KeyCode::Char('?') { '?' } else { '/' };
app.mode = Mode::Search;
app.line.clear();
}
KeyCode::Esc => {
app.exit_visual();
app.msg = None;
app.show_help = false;
}
KeyCode::Char('v' | 'V') => {
if app.mode == Mode::Visual {
app.exit_visual();
} else {
app.mode = Mode::Visual;
app.visual_anchor = Some(app.cur);
}
}
KeyCode::Char('c') if ctrl => app.info("type :q to quit vibox"),
KeyCode::F(1) => app.show_help = !app.show_help,
KeyCode::Char('q') if app.show_help => app.show_help = false,
KeyCode::Tab => toggle_pane(app),
KeyCode::Char('w') if ctrl => app.pending = Some('\u{17}'),
KeyCode::Char('j') | KeyCode::Down => step(app, count as isize),
KeyCode::Char('k') | KeyCode::Up => step(app, -(count as isize)),
KeyCode::Char('g') => app.pending = Some('g'),
KeyCode::Char('G') => {
if app.focus == Pane::Tracks {
if has_count {
app.goto(count - 1);
} else {
app.goto(usize::MAX);
}
} else {
app.move_folder(isize::MAX / 2);
}
}
KeyCode::Char('z') => app.pending = Some('z'),
KeyCode::Char(c @ ('H' | 'M' | 'L')) if app.focus == Pane::Tracks => {
app.cursor_to_screen(c);
}
KeyCode::Char('d') if ctrl => step(app, (app.track_h / 2) as isize),
KeyCode::Char('u') if ctrl => step(app, -((app.track_h / 2) as isize)),
KeyCode::Char('f') if ctrl => step(app, app.track_h as isize),
KeyCode::Char('b') if ctrl => step(app, -(app.track_h as isize)),
KeyCode::PageDown => step(app, app.track_h as isize),
KeyCode::PageUp => step(app, -(app.track_h as isize)),
KeyCode::Char('e') if ctrl => scroll_view(app, count as isize),
KeyCode::Char('y') if ctrl => scroll_view(app, -(count as isize)),
KeyCode::Char('n' | 'N') => {
let back = app.search_back ^ (key.code == KeyCode::Char('N'));
let pattern = app.last_search.clone();
if pattern.is_empty() {
app.error("no previous search");
} else if !app.search(&pattern, back, app.cur) {
app.error(format!("pattern not found: {pattern}"));
}
}
KeyCode::Enter => {
if app.focus == Pane::Folders {
match app.tab {
Tab::Playlists => app.open_playlist(),
Tab::Folders => app.open_folder(),
}
} else {
app.play_cursor();
}
}
KeyCode::Char(' ') => match app.audio.as_ref() {
Some(audio) => {
if audio.has_track() {
audio.toggle_pause();
} else {
app.play_cursor();
}
}
None => app.error("no audio device: playback is disabled"),
},
KeyCode::Char('h') | KeyCode::Left => seek(app, -(if has_count { count } else { 5 } as i64)),
KeyCode::Char('l') | KeyCode::Right => seek(app, if has_count { count } else { 5 } as i64),
KeyCode::Char('[' | ']') => {
let step = 100 * count as i64;
let delta = if key.code == KeyCode::Char('[') { -step } else { step };
match app.playing_track().map(|t| t.path.clone()) {
Some(path) => {
let offset = app.lyrics.nudge(&path, delta);
app.info(format!("lyrics {:+.1}s", offset as f64 / 1000.0));
}
None => app.error("nothing playing to shift the lyrics of"),
}
}
KeyCode::Char('>') => app.advance(count as isize, false),
KeyCode::Char('<') => app.advance(-(count as isize), false),
KeyCode::Char('+' | '=') => volume(app, if has_count { count as i32 } else { 5 }),
KeyCode::Char('-' | '_') => volume(app, -(if has_count { count as i32 } else { 5 })),
KeyCode::Char('m') => match app.audio.as_mut() {
Some(audio) => {
audio.toggle_mute();
let muted = audio.muted();
app.info(if muted { "muted" } else { "unmuted" });
}
None => app.error("no audio device: playback is disabled"),
},
KeyCode::Char('r') if ctrl => app.redo(),
KeyCode::Char('r') => {
app.repeat = app.repeat.next();
let name = app.repeat.name();
app.info(format!("repeat {name}"));
}
KeyCode::Char('s') => {
app.shuffle = !app.shuffle;
let on = app.shuffle;
app.info(if on { "shuffle on" } else { "shuffle off" });
}
KeyCode::Char('t') if app.focus == Pane::Folders => app.open_in_new_tab(),
KeyCode::Char('o' | 'O') if app.focus == Pane::Folders => {
app.mode = Mode::Command;
app.line_prefix = ':';
app.line = match app.tab {
Tab::Playlists => "mkplaylist ".to_string(),
Tab::Folders => "mkdir ".to_string(),
};
app.line_cur = app.line.chars().count();
}
KeyCode::Char('y') => {
if app.mode == Mode::Visual {
app.yank_selection();
} else {
app.pending = Some('y');
}
}
KeyCode::Char('d') => {
if app.mode == Mode::Visual {
delete_selection(app);
} else {
app.pending = Some('d');
}
}
KeyCode::Char('x') if app.focus == Pane::Tracks => delete_selection(app),
KeyCode::Char('p') => {
if !app.move_cut_here() && !app.copy_yank_here() {
app.paste_into_playlist();
}
}
KeyCode::Char('u') => app.undo(),
KeyCode::Char('J') => app.move_in_playlist(count as isize),
KeyCode::Char('K') => app.move_in_playlist(-(count as isize)),
KeyCode::Char('c') if app.focus == Pane::Tracks => app.begin_edit(),
KeyCode::Char('c') if app.focus == Pane::Folders => app.begin_sidebar_edit(),
KeyCode::Char('Z') => app.pending = Some('Z'),
_ => clear_count = false,
}
if clear_count {
app.count = None;
}
}
fn help_key(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let last = crate::ui::help_len().saturating_sub(1);
let scroll = |app: &mut App, delta: isize| {
app.help_scroll = (app.help_scroll as isize + delta).clamp(0, last as isize) as usize;
};
match key.code {
KeyCode::Char('q' | 'Q') | KeyCode::Esc | KeyCode::F(1) => {
app.show_help = false;
app.help_scroll = 0;
}
KeyCode::Char('j') | KeyCode::Down => scroll(app, 1),
KeyCode::Char('k') | KeyCode::Up => scroll(app, -1),
KeyCode::Char('d') if ctrl => scroll(app, 10),
KeyCode::Char('u') if ctrl => scroll(app, -10),
KeyCode::Char('g') => app.help_scroll = 0,
KeyCode::Char('G') => app.help_scroll = last,
_ => {}
}
}
fn pending_key(app: &mut App, prefix: char, key: KeyEvent, count: usize, has_count: bool) {
app.count = None;
match (prefix, key.code) {
('g', KeyCode::Char('g')) => {
if app.focus == Pane::Tracks {
app.goto(if has_count { count - 1 } else { 0 });
} else {
app.move_folder(isize::MIN / 2);
}
}
('g', KeyCode::Char('p')) => {
if let Some(playing) = app.playing
&& let Some(row) = app.view.iter().position(|&i| i == playing)
{
app.focus = Pane::Tracks;
app.goto(row);
} else {
app.error("nothing playing in this view");
}
}
('g', KeyCode::Char(c @ ('t' | 'T'))) => {
if app.focus == Pane::Folders {
app.tab = match app.tab {
Tab::Folders => Tab::Playlists,
Tab::Playlists => Tab::Folders,
};
if app.tab == Tab::Playlists {
app.reload_playlists();
}
} else {
app.cycle_tab(if c == 't' { 1 } else { -1 });
}
}
('z', KeyCode::Char(c @ ('z' | 't' | 'b'))) => app.scroll_cursor_to(c),
('y', KeyCode::Char('y')) => app.yank_selection(),
('d', KeyCode::Char('d')) => match (app.focus, app.tab) {
(Pane::Folders, Tab::Playlists) => app.delete_playlist(),
(Pane::Folders, Tab::Folders) => app.cut_folder(),
(Pane::Tracks, _) => delete_selection(app),
},
('Z', KeyCode::Char('Z' | 'Q')) => app.quit = true,
('\u{17}', KeyCode::Char('h' | 'k')) => app.focus = Pane::Folders,
('\u{17}', KeyCode::Char('l' | 'j')) => app.focus = Pane::Tracks,
('\u{17}', KeyCode::Char('w')) => toggle_pane(app),
_ => {}
}
}
fn delete_selection(app: &mut App) {
if app.playlist_view.is_some() {
app.remove_from_playlist();
} else {
app.cut_tracks();
}
}
fn toggle_pane(app: &mut App) {
app.focus = match app.focus {
Pane::Folders => Pane::Tracks,
Pane::Tracks => Pane::Folders,
};
}
fn step(app: &mut App, delta: isize) {
match (app.focus, app.tab) {
(Pane::Tracks, _) => app.move_cursor(delta),
(Pane::Folders, Tab::Folders) => app.move_folder(delta),
(Pane::Folders, Tab::Playlists) => app.move_playlist(delta),
}
}
fn scroll_view(app: &mut App, delta: isize) {
let max_top = app.view.len().saturating_sub(app.track_h.max(1));
app.top = (app.top as isize + delta).clamp(0, max_top as isize) as usize;
let cur = app.cur.clamp(app.top, app.top + app.track_h.saturating_sub(1));
app.goto(cur);
}
fn seek(app: &mut App, delta: i64) {
let Some(audio) = app.audio.as_ref() else {
app.error("no audio device: playback is disabled");
return;
};
if !audio.has_track() {
app.error("nothing playing");
return;
}
if let Err(e) = audio.seek_by(delta) {
app.error(format!("{e}"));
}
}
fn volume(app: &mut App, delta: i32) {
let Some(audio) = app.audio.as_mut() else {
app.error("no audio device: playback is disabled");
return;
};
audio.nudge_volume(delta);
let v = audio.volume();
app.info(format!("volume {v}%"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctrl_w_deletes_back_to_the_start_of_the_word() {
let text: Vec<char> = "late night mix".chars().collect();
assert_eq!(word_start(&text, text.len()), 11);
assert_eq!(word_start(&text, 11), 5);
assert_eq!(word_start(&text, 0), 0);
}
}