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
+148 -12
View File
@@ -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<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<()> {
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() {