Files
tubular/src/keys.rs
T

840 lines
35 KiB
Rust

use std::{fmt::Write as _, io};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
style::Stylize,
text::{Line, Span, Text},
};
use toml_edit::{Array, Item};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub enum Context {
Normal,
Info,
Description,
CollectionNormal,
CollectionInfo,
CollectionDescription,
SearchInput,
SelectorNormal,
SelectorInput,
TagList,
TagFilter,
TagInput,
CategoryNameInput,
Help,
}
const ALL: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SearchInput,
Context::SelectorNormal,
Context::SelectorInput,
Context::TagList,
Context::TagFilter,
Context::TagInput,
Context::CategoryNameInput,
Context::Help,
];
const VIEW: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
];
const COLLECTION: &[Context] = &[
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
];
const TEXT: &[Context] = &[
Context::SearchInput,
Context::SelectorInput,
Context::TagFilter,
Context::TagInput,
Context::CategoryNameInput,
];
const NON_TEXT: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SelectorNormal,
Context::TagList,
Context::Help,
];
struct Definition {
action: Action,
name: &'static str,
description: &'static str,
contexts: &'static [Context],
defaults: &'static [&'static str],
}
// The enum order, storage indices, documentation and default TOML all share this registry.
macro_rules! actions {
($($action:ident, $name:literal, $description:literal, $contexts:expr, [$($key:literal),*];)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub enum Action { $($action),* }
const DEFINITIONS: &[Definition] = &[
$(Definition {
action: Action::$action,
name: $name,
description: $description,
contexts: $contexts,
defaults: &[$($key),*],
}),*
];
};
}
actions! {
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"];
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"];
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-"];
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play checked videos in check order, or the selected video", VIEW, ["enter"];
VideoEnqueue, "video.enqueue", "Append checked videos in check order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"];
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
VideoClear, "video.select.clear", "Discard playback checks in the current view", VIEW, ["escape", "ctrl+space"];
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"];
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal, Context::CollectionNormal], ["f"];
CollectionRemoveSelected, "view.collection.remove.selected", "Remove the selected video from the current collection (also in its previews)", COLLECTION, ["x"];
CollectionRemoveBulk, "view.collection.remove.bulk", "Confirm removal of the current collection subset", COLLECTION, ["X"];
SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"];
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"];
SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"];
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
SelectorClose, "selector.close", "Close the category, channel, playlist, or collection picker", &[Context::SelectorNormal], ["escape"];
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
TagInputNext, "tags.input.next", "Select the next tag while editing the filter", &[Context::TagFilter], ["down", "ctrl+j"];
TagInputPrevious, "tags.input.previous", "Select the previous tag while editing the filter", &[Context::TagFilter], ["up", "ctrl+k"];
TagToggle, "tags.toggle", "Toggle the selected category tag", &[Context::TagList], ["space"];
TagNew, "tags.new", "Enter a new category tag name", &[Context::TagList], ["n"];
TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"];
TagCancel, "tags.cancel", "Discard category tag changes and close", &[Context::TagList], ["escape"];
DescriptionNext, "video.description.scroll.next", "Scroll the description down one line", &[Context::Description, Context::CollectionDescription], ["down", "ctrl+j"];
DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description, Context::CollectionDescription], ["up", "ctrl+k"];
DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description, Context::CollectionDescription], ["pagedown"];
DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description, Context::CollectionDescription], ["pageup"];
HelpNext, "help.scroll.next", "Scroll help down one line", &[Context::Help], ["j", "down", "ctrl+j"];
HelpPrevious, "help.scroll.previous", "Scroll help up one line", &[Context::Help], ["k", "up", "ctrl+k"];
HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"];
HelpPagePrevious, "help.page.previous", "Scroll help up one page", &[Context::Help], ["pageup"];
HelpClose, "help.close", "Close keyboard help", &[Context::Help], ["escape"];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Key {
code: KeyCode,
modifiers: KeyModifiers,
}
impl Key {
fn normalized(mut self) -> Self {
if self.code == KeyCode::BackTab {
self.code = KeyCode::Tab;
self.modifiers.insert(KeyModifiers::SHIFT);
}
if let KeyCode::Char(character) = self.code
&& !character.is_control()
&& self.modifiers.contains(KeyModifiers::SHIFT)
{
// Terminals usually report the already-shifted character. A single-character
// uppercase mapping also accepts synthetic shift+i as I without allocating.
let mut uppercase = character.to_uppercase();
if let Some(first) = uppercase.next()
&& uppercase.next().is_none()
{
self.code = KeyCode::Char(first);
}
self.modifiers.remove(KeyModifiers::SHIFT);
}
self
}
fn parse(description: &str) -> Result<Self, String> {
let mut remaining = description;
let mut modifiers = KeyModifiers::NONE;
loop {
if remaining.chars().count() == 1 {
break;
}
let Some((modifier, key)) = remaining.split_once('+') else {
break;
};
let flag = match modifier.to_ascii_lowercase().as_str() {
"ctrl" | "control" => KeyModifiers::CONTROL,
"alt" | "option" => KeyModifiers::ALT,
"super" | "cmd" | "command" => KeyModifiers::SUPER,
"shift" => KeyModifiers::SHIFT,
_ => {
return Err(format!(
"unknown modifier {modifier:?}; use ctrl, alt, super or shift"
));
}
};
if modifiers.contains(flag) {
return Err(format!("repeated modifier {modifier:?}"));
}
modifiers.insert(flag);
remaining = key;
}
let mut characters = remaining.chars();
let code = match (characters.next(), characters.next()) {
(Some(character), None) if !character.is_control() => KeyCode::Char(character),
_ => match remaining.to_ascii_lowercase().as_str() {
"escape" | "esc" => KeyCode::Esc,
"enter" | "return" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"space" => KeyCode::Char(' '),
name => {
let function = name
.strip_prefix('f')
.and_then(|number| number.parse::<u8>().ok());
match function {
Some(number @ 1..=24) => KeyCode::F(number),
_ => {
return Err(format!(
"unknown key {remaining:?}; use one character, a named key or f1..f24"
));
}
}
}
},
};
Ok(Self { code, modifiers }.normalized())
}
fn label(self) -> String {
let mut text = String::new();
for (flag, name) in [
(KeyModifiers::CONTROL, "Ctrl+"),
(KeyModifiers::ALT, "Alt+"),
(KeyModifiers::SUPER, "Super+"),
(KeyModifiers::SHIFT, "Shift+"),
] {
if self.modifiers.contains(flag) {
text.push_str(name);
}
}
match self.code {
KeyCode::Char(' ') => text.push_str("Space"),
KeyCode::Char(character) => text.push(character),
KeyCode::F(number) => {
let _ = write!(text, "F{number}");
}
code => text.push_str(match code {
KeyCode::Esc => "Esc",
KeyCode::Enter => "Enter",
KeyCode::Tab => "Tab",
KeyCode::Up => "↑",
KeyCode::Down => "↓",
KeyCode::Left => "←",
KeyCode::Right => "→",
KeyCode::Home => "Home",
KeyCode::End => "End",
KeyCode::PageUp => "PgUp",
KeyCode::PageDown => "PgDn",
KeyCode::Backspace => "Backspace",
KeyCode::Delete => "Del",
_ => unreachable!("only supported normalized keys have labels"),
}),
}
text
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bindings {
labels: Vec<Vec<String>>,
contexts: [Vec<(Key, Action)>; ALL.len()],
}
impl Default for Bindings {
fn default() -> Self {
Self::build(default_keys()).expect("the default key registry must be valid")
}
}
fn default_keys() -> Vec<Vec<Key>> {
DEFINITIONS
.iter()
.map(|definition| {
definition
.defaults
.iter()
.map(|key| Key::parse(key).expect("default key descriptions must be valid"))
.collect()
})
.collect()
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
impl Bindings {
pub fn from_toml(item: Option<&Item>) -> io::Result<Self> {
let mut keys = default_keys();
if let Some(item) = item {
let mut overridden = vec![false; DEFINITIONS.len()];
Self::read_overrides(item, "", &mut keys, &mut overridden)?;
}
Self::build(keys)
}
fn read_overrides(
item: &Item,
path: &str,
keys: &mut [Vec<Key>],
overridden: &mut [bool],
) -> io::Result<()> {
if let Some(definition) = DEFINITIONS
.iter()
.find(|definition| definition.name == path)
{
if std::mem::replace(&mut overridden[definition.action as usize], true) {
return Err(invalid(format!(
"keys.{path}: duplicate action override; use only one quoted or nested dotted path for this action"
)));
}
let mut aliases = Vec::new();
let mut add = |description: &str| -> io::Result<()> {
let key = Key::parse(description).map_err(|error| {
invalid(format!(
"keys.{path}: invalid binding {description:?}: {error}"
))
})?;
if !aliases.contains(&key) {
aliases.push(key);
}
Ok(())
};
if let Some(description) = item.as_str() {
add(description)?;
} else if let Some(array) = item.as_array() {
for (index, value) in array.iter().enumerate() {
let description = value.as_str().ok_or_else(|| {
invalid(format!(
"keys.{path}[{index}]: expected a key description string"
))
})?;
add(description)?;
}
} else {
return Err(invalid(format!(
"keys.{path}: expected a string or array of strings; use [] to disable"
)));
}
keys[definition.action as usize] = aliases;
return Ok(());
}
if !path.is_empty()
&& !DEFINITIONS.iter().any(|definition| {
definition
.name
.strip_prefix(path)
.is_some_and(|suffix| suffix.starts_with('.'))
})
{
return Err(invalid(format!(
"keys.{path}: unknown action or action group; consult --default-config for valid paths"
)));
}
let table = item.as_table_like().ok_or_else(|| {
invalid(format!(
"keys{}: expected a table of action bindings",
if path.is_empty() {
String::new()
} else {
format!(".{path}")
}
))
})?;
for (name, value) in table.iter() {
let child = if path.is_empty() {
name.to_owned()
} else {
format!("{path}.{name}")
};
Self::read_overrides(value, &child, keys, overridden)?;
}
Ok(())
}
fn build(keys: Vec<Vec<Key>>) -> io::Result<Self> {
let mut contexts: [Vec<(Key, Action)>; ALL.len()] = std::array::from_fn(|_| Vec::new());
for definition in DEFINITIONS {
for &context in definition.contexts {
let entries = &mut contexts[context as usize];
for &key in &keys[definition.action as usize] {
if let Some((_, previous)) = entries.iter_mut().find(|(bound, _)| *bound == key)
{
*previous = overlap_winner(context, *previous, definition.action).ok_or_else(|| {
invalid(format!(
"keys: binding {} collides in {context:?} between {} and {}; rebind or disable one action",
key.label(), DEFINITIONS[*previous as usize].name, definition.name,
))
})?;
} else {
entries.push((key, definition.action));
}
}
}
}
Ok(Self {
labels: keys
.into_iter()
.map(|aliases| aliases.into_iter().map(Key::label).collect())
.collect(),
contexts,
})
}
pub fn resolve(&self, context: Context, event: KeyEvent) -> Option<Action> {
let key = Key {
code: event.code,
modifiers: event.modifiers,
}
.normalized();
self.contexts[context as usize]
.iter()
.find_map(|(bound, action)| (*bound == key).then_some(*action))
}
pub fn label(&self, action: Action) -> &str {
self.labels[action as usize]
.first()
.map_or("unbound", String::as_str)
}
pub fn help_text(&self) -> Text<'static> {
use Action::*;
type Row = (&'static [Action], &'static str);
let sections: &[(&str, &[Row])] = &[
(
"VIEWS AND VIDEOS",
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(
&[VideoPlay],
"Play checked videos in check order, or the selected video",
),
(
&[VideoEnqueue],
"Append checked videos in check order, or the selected video, to Terebi's queue",
),
(
&[VideoToggle],
"Check / uncheck the selected video for playback",
),
(&[VideoClear], "Clear playback checks in the current view"),
(
&[VideoOpen],
"Open the selected YouTube video in the default browser",
),
(&[VideoCopyUrl], "Copy the selected video's URL"),
(
&[WatchStatusWatching],
"Mark selected as watching (clear watched)",
),
(
&[WatchStatusWatched],
"Mark selected as watched (leave watching)",
),
(&[SearchEdit], "Edit general or current-category search"),
(&[ViewSearch], "Go to general YouTube Search"),
(&[ViewFeed], "Go to the combined subscription Feed"),
(
&[InfoOverview, InfoDescription],
"Info → full description → info",
),
(&[VideoChannel], "Browse the selected video's channel"),
(&[ChannelBrowse], "Choose a saved channel"),
(
&[ChannelPlaylists],
"Browse the current channel's playlists",
),
(&[CategoryBrowse], "Choose a saved category"),
(&[ChannelTags], "Edit the selected channel's tags"),
(&[CategoryTags], "Edit all channels in a category"),
(
&[CategoryRename],
"Rename the current category (including scoped collections)",
),
(
&[CategorySave],
"Save a combined category view as a new category",
),
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
(&[ViewPrevious], "Back (preview close takes priority)"),
(&[ViewNext], "Forward after going back"),
(&[VideoFirst], "First loaded video"),
(&[VideoLast], "Last loaded video"),
(&[ViewRefresh], "Reload config and refresh"),
(
&[ProviderChoose],
"Choose the browsing provider (NORMAL only)",
),
(&[Help], "Open / close this reference"),
(&[Quit, Interrupt], "Quit"),
],
),
(
"WATCH LATER",
&[
(&[WatchLaterSave], "Save the selected video"),
(
&[WatchLaterBrowse],
"Choose all-categories or category watch later",
),
(
&[WatchLaterBrowseCategory],
"Browse current category's watch later",
),
(&[WatchLaterFilter], "Cycle all / unwatched / watched"),
],
),
(
"SAVED VIDEOS",
&[
(
&[SavedVideoSave],
"Save the selected video (also in previews)",
),
(
&[SavedVideoBrowse],
"Choose all-categories or category saved videos",
),
(
&[SavedVideoBrowseCategory],
"Browse current category's saved videos",
),
],
),
(
"CURRENTLY WATCHING",
&[(&[WatchingBrowse], "Browse started, unfinished videos")],
),
(
"COLLECTION LISTS",
&[
(
&[CollectionRemoveSelected],
"Remove selected from the current collection only",
),
(
&[CollectionRemoveBulk],
"Confirm removal of current list / category / filtered subset",
),
(
&[],
"Previews inherit their underlying collection's removal scope",
),
(
&[],
"Bulk confirmation: y / Enter confirms; n / Esc cancels",
),
],
),
(
"FULL DESCRIPTION",
&[
(&[DescriptionNext, DescriptionPrevious], "Scroll lines"),
(
&[DescriptionPageNext, DescriptionPagePrevious],
"Scroll pages",
),
(&[VideoNext, VideoPrevious], "Change the selected video"),
(&[InfoOverview], "Return to video info"),
(&[InfoHide], "Close the preview"),
],
),
(
"CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR",
&[
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
(&[SelectorEdit], "Enter filter INSERT mode"),
(
&[SelectorInputNext, SelectorInputPrevious],
"Move while filtering",
),
(
&[SelectorOpen, InputSubmit],
"Browse checked categories, or the selected match",
),
(
&[SelectorToggle],
"Check categories for a combined feed (NORMAL only)",
),
(
&[SelectorPin],
"Pin / unpin a category (category picker only)",
),
(
&[CategoryRename],
"Rename the selected category, not combined / channel rows",
),
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
],
),
(
"CHANNEL TAGS",
&[
(&[TagNext, TagPrevious], "Move between tags"),
(&[TagEdit], "Enter filter INSERT mode"),
(&[TagInputNext, TagInputPrevious], "Move while filtering"),
(
&[InputSubmit, InputCancel],
"Leave tag filter without saving",
),
(&[TagToggle], "Toggle a tag"),
(&[TagNew], "Enter a new tag name"),
(
&[CategoryRename],
"Rename a tag; existing category renames save immediately",
),
(&[TagSave], "Save tags"),
(&[InputSubmit], "Stage a new tag name"),
(&[TagCancel], "Cancel tag changes"),
(&[InputCancel], "Abandon a new tag name"),
],
),
(
"TEXT INPUT",
&[
(
&[InputSubmit],
"Submit search / stage tag / save category name",
),
(
&[InputCancel],
"Leave INSERT / abandon tag or category name",
),
(&[DeleteCharacter], "Delete one character"),
(&[DeleteWord], "Delete previous word"),
(&[DeleteLine], "Clear the line"),
],
),
(
"HELP WINDOW",
&[
(&[HelpNext, HelpPrevious], "Scroll lines"),
(&[HelpPageNext, HelpPagePrevious], "Scroll pages"),
(&[Help, HelpClose], "Close help without changing view"),
(&[Interrupt], "Quit from any mode or popup"),
],
),
];
let mut text = Text::default();
for &(heading, rows) in sections {
text.push_line("");
text.push_line(Line::from(Span::raw(heading).bold()));
text.push_line("");
for &(actions, description) in rows {
if actions == [InfoOverview, InfoDescription]
&& self.labels[InfoOverview as usize] != self.labels[InfoDescription as usize]
{
for (action, description) in [
(InfoOverview, "Open video info"),
(InfoDescription, "Open the full description"),
] {
text.push_line(format!(
"{:<18} {}",
self.help_keys(&[action]),
description
));
}
} else {
text.push_line(format!("{:<18} {}", self.help_keys(actions), description));
}
}
}
text
}
fn help_keys(&self, actions: &[Action]) -> String {
use Action::*;
let matches = |action: Action, expected: &[&str]| {
self.labels[action as usize]
.iter()
.map(String::as_str)
.eq(expected.iter().copied())
};
let movement = match actions {
[VideoNext, VideoPrevious] | [HelpNext, HelpPrevious] => {
matches(actions[0], &["j", "↓", "Ctrl+j"])
&& matches(actions[1], &["k", "↑", "Ctrl+k"])
}
[SelectorNext, SelectorPrevious] | [TagNext, TagPrevious] => {
matches(actions[0], &["j", "↓"]) && matches(actions[1], &["k", "↑"])
}
_ => false,
};
if movement {
return if actions == [HelpNext, HelpPrevious] {
"j/k ↑/↓ Ctrl-J/K".into()
} else {
"j / k or arrows".into()
};
}
if matches!(
actions,
[DescriptionNext, DescriptionPrevious]
| [SelectorInputNext, SelectorInputPrevious]
| [TagInputNext, TagInputPrevious]
) && matches(actions[0], &["↓", "Ctrl+j"])
&& matches(actions[1], &["↑", "Ctrl+k"])
{
return "↑/↓, Ctrl-J/K".into();
}
if actions == [ViewTabNext, ViewTabPrevious]
&& matches(ViewTabNext, &["Tab", "Ctrl+Tab"])
&& matches(ViewTabPrevious, &["Shift+Tab"])
{
return "Tab / Shift-Tab".into();
}
let mut groups = Vec::new();
for &action in actions {
let labels = &self.labels[action as usize];
let group = if labels.is_empty() {
"unbound".into()
} else {
labels.join(", ")
};
if !groups.contains(&group) {
groups.push(group);
}
}
groups.join(" / ")
}
}
fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Action> {
use Action::*;
let pair = |a, b| (first == a && second == b) || (first == b && second == a);
if first == second {
return Some(first);
}
if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) {
return Some(
if matches!(context, Context::Info | Context::CollectionInfo) {
InfoDescription
} else {
InfoOverview
},
);
}
if (pair(InfoHide, ViewPrevious) || pair(InfoHide, VideoClear))
&& matches!(
context,
Context::Info
| Context::Description
| Context::CollectionInfo
| Context::CollectionDescription
)
{
return Some(InfoHide);
}
if matches!(
context,
Context::Description | Context::CollectionDescription
) {
if pair(DescriptionNext, VideoNext) {
return Some(DescriptionNext);
}
if pair(DescriptionPrevious, VideoPrevious) {
return Some(DescriptionPrevious);
}
}
None
}
pub fn default_config() -> String {
let mut text = String::from(
"[settings]\npinned_categories = []\n\n[channels]\n\n[categories]\n\n[keys]\n",
);
for definition in DEFINITIONS {
let mut aliases = Array::new();
for &key in definition.defaults {
aliases.push(key);
}
let _ = writeln!(text, "# {}", definition.description);
let _ = writeln!(text, "{} = {}", definition.name, aliases);
}
text
}
#[cfg(test)]
#[path = "../tests/unit/keys.rs"]
mod tests;