fix: insert bracketed paste as one input event

This commit is contained in:
2026-10-11 20:02:52 +02:00
parent 2f11bdd40a
commit af8032f12f
2 changed files with 150 additions and 12 deletions
+2
View File
@@ -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. 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`) ### 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. - **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes.
+147 -11
View File
@@ -10,6 +10,9 @@
//! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U. //! 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. //! 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. //! 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. //! 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. //! 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. //! Short terminals keep the selected tab row visible while reserving space for the other panes.
@@ -109,7 +112,10 @@ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
crossterm::{ crossterm::{
cursor::SetCursorStyle, cursor::SetCursorStyle,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers,
},
execute, execute,
}, },
layout::{Constraint, Layout, Rect}, 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 { fn key(&mut self, key: KeyEvent) -> bool {
let action = self.config.keys.resolve(self.key_context(), key); let action = self.config.keys.resolve(self.key_context(), key);
if action == Some(Action::Interrupt) { if action == Some(Action::Interrupt) {
@@ -2224,14 +2255,22 @@ impl App {
} }
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
struct ResetCursorStyle; struct RestoreTerminalInput;
impl Drop for ResetCursorStyle { impl Drop for RestoreTerminalInput {
fn drop(&mut self) { fn drop(&mut self) {
let _ = execute!(io::stdout(), SetCursorStyle::DefaultUserShape); let _ = execute!(
io::stdout(),
DisableBracketedPaste,
SetCursorStyle::DefaultUserShape
);
} }
} }
execute!(io::stdout(), SetCursorStyle::SteadyBlock)?; let _terminal_input = RestoreTerminalInput;
let _cursor_style = ResetCursorStyle; execute!(
io::stdout(),
EnableBracketedPaste,
SetCursorStyle::SteadyBlock
)?;
self.thumbnail.query_terminal(); self.thumbnail.query_terminal();
if !self.query.trim().is_empty() { if !self.query.trim().is_empty() {
self.search(); self.search();
@@ -2240,14 +2279,18 @@ impl App {
loop { loop {
self.update(); self.update();
terminal.draw(|frame| self.draw(frame, started.elapsed()))?; terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
if event::poll(Duration::from_millis(100))? if event::poll(Duration::from_millis(100))? {
&& let Event::Key(key) = event::read()? match event::read()? {
&& key.kind != KeyEventKind::Release Event::Key(key) if key.kind != KeyEventKind::Release => {
&& self.key(key) if self.key(key) {
{
return Ok(()); return Ok(());
} }
} }
Event::Paste(text) => self.paste(&text),
_ => {}
}
}
}
} }
} }
@@ -2311,6 +2354,18 @@ fn edit_input(text: &mut String, key: KeyEvent, action: Option<Action>) {
} }
} }
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<()> { fn main() -> io::Result<()> {
let options = Options::parse(); let options = Options::parse();
if options.default_config { 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) { fn loaded_channel_views(app: &mut App) {
let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
for (label, identity) in app.config.saved_channels() { for (label, identity) in app.config.saved_channels() {