feat: delete words and lines in text inputs

This commit is contained in:
2026-10-09 23:53:38 +02:00
parent 7367b2fc4b
commit ce68e254e1
+121 -11
View File
@@ -3,6 +3,9 @@
//! Run `cargo run -- "rust programming"`; macOS defaults to IINA, other platforms to mpv. //! Run `cargo run -- "rust programming"`; macOS defaults to IINA, other platforms to mpv.
//! Without a CLI query, start in insert mode; otherwise search and start in normal mode. //! Without a CLI query, start in insert mode; otherwise search and start in normal mode.
//! Insert: Enter submits a changed query and returns to normal; Esc returns without searching. //! Insert: Enter submits a changed query and returns to normal; Esc returns without searching.
//! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word.
//! Cmd/Super+Backspace or Ctrl-U clears the line; plain Backspace removes one character.
//! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U.
//! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query. //! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query.
//! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages. //! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages.
//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells. //! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells.
@@ -46,7 +49,7 @@ use youtube::{Job, Playback, Video};
#[command( #[command(
version, version,
about = "Search YouTube and stream videos to your chosen player", about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search/tag views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel; other creators, omitted tags, and empty tag arrays are retained.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\"" after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search/tag views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel; other creators, omitted tags, and empty tag arrays are retained.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)] )]
struct Options { struct Options {
/// Player executable (IINA or a player supporting '-' for standard input). /// Player executable (IINA or a player supporting '-' for standard input).
@@ -359,10 +362,7 @@ impl App {
dialog.error = None; dialog.error = None;
} }
} }
KeyCode::Backspace => { _ if delete_input(name, key) => {}
name.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => name.clear(),
KeyCode::Char(c) KeyCode::Char(c)
if !key if !key
.modifiers .modifiers
@@ -560,12 +560,7 @@ impl App {
self.search(); self.search();
self.mode = Mode::Normal; self.mode = Mode::Normal;
} }
KeyCode::Backspace => { _ if delete_input(&mut self.query, key) => {}
self.query.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.query.clear();
}
KeyCode::Char(c) KeyCode::Char(c)
if !key if !key
.modifiers .modifiers
@@ -949,6 +944,33 @@ fn preview_area(area: Rect) -> Rect {
) )
} }
fn delete_input(text: &mut String, key: KeyEvent) -> bool {
match key.code {
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => text.clear(),
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => text.clear(),
KeyCode::Backspace | KeyCode::Char('w')
if key.modifiers.contains(if key.code == KeyCode::Backspace {
KeyModifiers::ALT
} else {
KeyModifiers::CONTROL
}) =>
{
let keep = text
.char_indices()
.rev()
.skip_while(|(_, c)| c.is_whitespace())
.find(|(_, c)| c.is_whitespace())
.map_or(0, |(index, c)| index + c.len_utf8());
text.truncate(keep);
}
KeyCode::Backspace => {
text.pop();
}
_ => return false,
}
true
}
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
let options = Options::parse(); let options = Options::parse();
let config_path = config::config_path()?; let config_path = config::config_path()?;
@@ -969,6 +991,94 @@ mod tests {
) )
} }
#[test]
fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() {
for tag_input in [false, true] {
for (input, code, modifiers, expected) in [
(
"first 日本語 ",
KeyCode::Backspace,
KeyModifiers::ALT,
"first ",
),
(
"first\u{3000}cafe\u{301} ",
KeyCode::Char('w'),
KeyModifiers::CONTROL,
"first\u{3000}",
),
("word", KeyCode::Backspace, KeyModifiers::ALT, ""),
(" \u{3000} ", KeyCode::Backspace, KeyModifiers::ALT, ""),
("", KeyCode::Backspace, KeyModifiers::ALT, ""),
("first 日本語", KeyCode::Backspace, KeyModifiers::SUPER, ""),
(
"first 日本語",
KeyCode::Backspace,
KeyModifiers::SUPER | KeyModifiers::ALT,
"",
),
(
"first 日本語",
KeyCode::Char('u'),
KeyModifiers::CONTROL,
"",
),
(
"first 日本語",
KeyCode::Backspace,
KeyModifiers::NONE,
"first 日本",
),
] {
let mut app = app(&["tubular"]);
if tag_input {
app.mode = Mode::Normal;
app.category_dialog = Some(CategoryDialog {
channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".into(),
handle: "@jawed".into(),
label: "jawed".into(),
tags: BTreeMap::new(),
selection: ListState::default(),
name: Some(input.into()),
error: None,
});
} else {
app.query = input.into();
}
app.key(KeyEvent::new(code, modifiers));
let edited = app
.category_dialog
.as_ref()
.and_then(|dialog| dialog.name.as_deref())
.unwrap_or(&app.query);
assert_eq!(
edited, expected,
"{tag_input}: {input:?} {code:?} {modifiers:?}"
);
assert!(app.job.is_none());
assert_eq!(
app.mode,
if tag_input {
Mode::Normal
} else {
Mode::Insert
}
);
}
}
let mut app = app(&["tubular"]);
app.query = "keep this query".into();
app.mode = Mode::Normal;
for (code, modifiers) in [
(KeyCode::Backspace, KeyModifiers::ALT),
(KeyCode::Backspace, KeyModifiers::SUPER),
(KeyCode::Char('w'), KeyModifiers::CONTROL),
] {
app.key(KeyEvent::new(code, modifiers));
assert_eq!(app.query, "keep this query");
}
}
#[test] #[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() { fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = app(&["tubular"]); let mut app = app(&["tubular"]);