835 lines
35 KiB
Rust
835 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"];
|
|
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 or channel", &[Context::SelectorNormal], ["j", "down"];
|
|
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[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, or collection filter", &[Context::SelectorNormal], ["i"];
|
|
SelectorOpen, "selector.open", "Open the selected category, channel, 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, 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"),
|
|
(&[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 / 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;
|