diff --git a/README.md b/README.md index 16dadee..9eea95c 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ These shortcuts work in search, category/channel/tag-filter, and new-tag fields: If the terminal intercepts modified Backspace rather than transmitting it, use Ctrl-W / Ctrl-U or configure equivalent terminal key mappings. Input fields show a block cursor. +In terminals supporting bracketed paste, clipboard text is inserted in one operation into the active search, filter, or new-tag field; filters update once for the whole paste. Pasting never submits input or triggers shortcuts. Line breaks and tabs become spaces, other control characters are removed, and pastes outside text-entry mode are ignored. Bracketed-paste mode is disabled again when Tubular exits. Terminals without bracketed-paste support still deliver pasted text as ordinary keystrokes. + ### Category browser (`c`) - **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes. diff --git a/src/main.rs b/src/main.rs index dfa9a32..2025c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ //! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U. //! Active text fields show a steady block cursor at the scrolled end; other views hide it. //! The terminal's configured default cursor shape is restored when the app exits. +//! Bracketed paste inserts into the active text field at once, without submitting or triggering shortcuts. +//! Pasted line breaks/tabs become spaces; other controls are removed. Non-input contexts ignore paste. +//! Terminal bracketed-paste mode is disabled on exit, including unwinding after an error. //! Normal: j/k selects, Enter opens the video in Terebi, and i edits the query. //! View tabs wrap whole labels into rows using terminal-cell widths and reflow on resize. //! Short terminals keep the selected tab row visible while reserving space for the other panes. @@ -109,7 +112,10 @@ use ratatui::{ DefaultTerminal, Frame, crossterm::{ cursor::SetCursorStyle, - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, + KeyEventKind, KeyModifiers, + }, execute, }, layout::{Constraint, Layout, Rect}, @@ -1546,6 +1552,31 @@ impl App { } } + fn paste(&mut self, text: &str) { + match self.key_context() { + Context::SearchInput => paste_input(&mut self.query, text), + Context::SelectorInput => { + let browser = self.browser.as_mut().expect("selector input is active"); + paste_input(&mut browser.query, text); + browser.refilter(); + } + Context::TagFilter => { + let dialog = self.category_dialog.as_mut().expect("tag filter is active"); + paste_input(&mut dialog.query, text); + dialog.refilter(); + } + Context::TagInput => { + let name = self + .category_dialog + .as_mut() + .and_then(|dialog| dialog.name.as_mut()) + .expect("new-tag input is active"); + paste_input(name, text); + } + _ => {} + } + } + fn key(&mut self, key: KeyEvent) -> bool { let action = self.config.keys.resolve(self.key_context(), key); if action == Some(Action::Interrupt) { @@ -2224,14 +2255,22 @@ impl App { } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - struct ResetCursorStyle; - impl Drop for ResetCursorStyle { + struct RestoreTerminalInput; + impl Drop for RestoreTerminalInput { fn drop(&mut self) { - let _ = execute!(io::stdout(), SetCursorStyle::DefaultUserShape); + let _ = execute!( + io::stdout(), + DisableBracketedPaste, + SetCursorStyle::DefaultUserShape + ); } } - execute!(io::stdout(), SetCursorStyle::SteadyBlock)?; - let _cursor_style = ResetCursorStyle; + let _terminal_input = RestoreTerminalInput; + execute!( + io::stdout(), + EnableBracketedPaste, + SetCursorStyle::SteadyBlock + )?; self.thumbnail.query_terminal(); if !self.query.trim().is_empty() { self.search(); @@ -2240,12 +2279,16 @@ impl App { loop { self.update(); terminal.draw(|frame| self.draw(frame, started.elapsed()))?; - if event::poll(Duration::from_millis(100))? - && let Event::Key(key) = event::read()? - && key.kind != KeyEventKind::Release - && self.key(key) - { - return Ok(()); + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if self.key(key) { + return Ok(()); + } + } + Event::Paste(text) => self.paste(&text), + _ => {} + } } } } @@ -2311,6 +2354,18 @@ fn edit_input(text: &mut String, key: KeyEvent, action: Option) { } } +fn paste_input(input: &mut String, text: &str) { + // Inputs are single-line; pasted control characters must never become terminal commands. + if !text.chars().any(char::is_control) { + input.push_str(text); + } else { + input.extend(text.chars().filter_map(|c| match c { + '\r' | '\n' | '\t' => Some(' '), + _ => (!c.is_control()).then_some(c), + })); + } +} + fn main() -> io::Result<()> { let options = Options::parse(); if options.default_config { @@ -2337,6 +2392,87 @@ mod tests { ) } + #[test] + fn paste_targets_only_active_inputs_and_refilters_without_executing_shortcuts() { + let mut app = app(&["tubular"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("Haru 日本語\r\ns\t\x1b[31m"); + assert_eq!(app.query, "Haru 日本語 s [31m"); + assert_eq!(app.mode, Mode::Insert); + assert!(app.submitted_query.is_empty()); + assert!(app.job.is_none(), "paste must not submit a search"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.paste("i\nq"); + assert_eq!(app.query, "Haru 日本語 s [31m"); + assert_eq!(app.mode, Mode::Normal); + + app.config.categories = [("Alpha".into(), vec![]), ("日本語".into(), vec![])].into(); + app.config.channels = [ + ( + "@Alpha".into(), + config::normalize_channel("@Alpha").unwrap(), + ), + ( + "@日本語".into(), + config::normalize_channel("@日本語").unwrap(), + ), + ] + .into(); + for kind in [BrowserKind::Categories, BrowserKind::Channels] { + app.open_browser(kind); + app.paste("Alpha"); + assert!(app.browser.as_ref().unwrap().query.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("日本語"); + let browser = app.browser.as_ref().unwrap(); + assert_eq!(browser.matches, [1]); + assert_eq!(browser.selection.selected(), Some(0)); + assert!(browser.choices[browser.matches[0]].0.contains("日本語")); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@a".into(), + channel: String::new(), + }, + "Creator".into(), + [("Alpha".into(), true), ("日本語".into(), false)].into(), + )); + app.paste("i\n "); + assert!(app.category_dialog.as_ref().unwrap().tags["Alpha"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("日本語"); + assert_eq!(app.category_dialog.as_ref().unwrap().matches, [1]); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().tags["日本語"]); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.paste("New\nTag\t?"); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("New Tag ?") + ); + assert!( + app.help_popup.is_none(), + "pasted ? must remain literal text" + ); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().tags["New Tag ?"]); + assert!( + !app.config.categories.contains_key("New Tag ?"), + "paste must not save tags" + ); + app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + app.paste("n\nq"); + assert!(app.category_dialog.as_ref().unwrap().name.is_none()); + assert!( + app.help_popup.is_some(), + "paste must not close help or operate on its dialog" + ); + } + fn loaded_channel_views(app: &mut App) { let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; for (label, identity) in app.config.saved_channels() {