fix: insert bracketed paste as one input event
This commit is contained in:
@@ -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.
|
||||
|
||||
+148
-12
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user