diff --git a/src/core/engine/keys.rs b/src/core/engine/keys.rs index 0f8762c1..ade41d23 100644 --- a/src/core/engine/keys.rs +++ b/src/core/engine/keys.rs @@ -7712,10 +7712,10 @@ impl Engine { /// Route pasted text to the correct input context. /// - /// Checks active contexts in priority order (terminal, picker, search, - /// SC commit, extension sidebar, AI chat) before falling through to - /// mode-based dispatch. Both backends call this instead of reimplementing - /// the priority chain. + /// Checks active contexts in priority order (terminal, picker, explorer + /// rename, search, SC commit, extension sidebar, AI chat) before falling + /// through to mode-based dispatch. Both backends call this instead of + /// reimplementing the priority chain. pub fn route_paste(&mut self, text: &str) { let first_line = text.lines().next().unwrap_or(""); @@ -7748,6 +7748,28 @@ impl Engine { return; } + // Tree inline-edit (explorer rename). Mirrors the selection-replace + // + insert behaviour `handle_explorer_rename_key`'s own `ctrl+v` + // branch already has — that branch reads `self.clipboard_read` + // directly and only ever fired from a raw `KeyPressed("v", ctrl)`, + // which quadraui's GTK runner no longer delivers (#593: Ctrl+V is + // intercepted and turned into this fn's `text` argument instead). + if let Some(state) = self.explorer_rename.as_mut() { + if !first_line.is_empty() { + if let Some(anchor) = state.selection_anchor.take() { + let lo = anchor.min(state.cursor); + let hi = anchor.max(state.cursor); + if lo != hi { + state.input.drain(lo..hi); + state.cursor = lo; + } + } + state.input.insert_str(state.cursor, first_line); + state.cursor += first_line.len(); + } + return; + } + if self.search_has_focus { let focus_id = self.search_panel_form_focus.borrow().clone(); if focus_id.is_some() { diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 9a12b42a..a6e1f7bd 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -19957,6 +19957,26 @@ fn test_explorer_rename_ctrl_v_paste() { assert_eq!(rename.input, "pasted_name.rs"); } +/// #593: `UiEvent::ClipboardPaste` (delivered by quadraui's GTK runner when +/// it intercepts Ctrl+V, and by TUI's crossterm bracketed-paste) reaches the +/// engine as a call to `route_paste`, not a raw `KeyPressed("v", ctrl)` — +/// so the explorer-rename ctrl+v branch in `handle_explorer_rename_key` +/// (exercised by `test_explorer_rename_ctrl_v_paste` above) never fires for +/// it. `route_paste` needs its own explorer-rename arm, checked before this +/// fix landed: it fell through to the mode-based dispatch at the bottom of +/// `route_paste` and did nothing, since `start_explorer_rename` doesn't +/// change `engine.mode`. +#[test] +fn test_explorer_rename_route_paste() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Selection is "hello" (anchor=0, cursor=5) — route_paste must replace it, + // same as the ctrl+v key path does. + e.route_paste("pasted_name"); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "pasted_name.rs"); +} + #[test] fn test_explorer_rename_ctrl_c_copies_selection() { use std::sync::{Arc, Mutex}; diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 74f749b7..51a3fa28 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -1294,10 +1294,6 @@ enum Msg { SelectForDiff(PathBuf), /// Open a vsplit diff: current file is right side, stored path is left. DiffWithSelected(PathBuf), - /// GDK clipboard text arrived for pasting into command/search/insert input. - ClipboardPasteToInput { - text: String, - }, /// Toggle the integrated terminal panel open/closed. ToggleTerminal, /// Toggle the "terminal maximized" state (panel fills editor area). @@ -2463,7 +2459,6 @@ impl App { | Msg::CopyRelativePath(_) | Msg::SelectForDiff(_) | Msg::DiffWithSelected(_) - | Msg::ClipboardPasteToInput { .. } | Msg::WindowClosing { .. } => { self.handle_file_ops_msg(msg); } @@ -2986,25 +2981,6 @@ impl App { #[allow(clippy::too_many_lines)] fn handle_key_press(&mut self, key_name: String, unicode: Option, ctrl: bool, alt: bool) { - // Handle Ctrl-Shift-V paste (sent as synthetic "PasteClipboard" key): - // do async GDK clipboard read → ClipboardPasteToInput - if key_name == "PasteClipboard" { - if let Some(display) = gdk::Display::default() { - let sender = self.sender.clone(); - display - .clipboard() - .read_text_async(gtk4::gio::Cancellable::NONE, move |result| { - let text = result - .ok() - .flatten() - .map(|s| s.to_string()) - .unwrap_or_default(); - sender.send(Msg::ClipboardPasteToInput { text }).ok(); - }); - } - return; - } - // Dismiss context menu on any key press (Escape, or j/k for nav, Enter to confirm). if self.engine.borrow().context_menu.is_some() { let mut engine = self.engine.borrow_mut(); @@ -3185,25 +3161,12 @@ impl App { let mapped = map_gtk_key_name(key_name.as_str()); if engine.dialog.is_some() { engine.handle_key(mapped, unicode, ctrl); - } else if ctrl && mapped == "v" { - drop(engine); - if let Some(display) = gdk::Display::default() { - let sender = self.sender.clone(); - display.clipboard().read_text_async( - gtk4::gio::Cancellable::NONE, - move |result| { - let text = result - .ok() - .flatten() - .map(|s| s.to_string()) - .unwrap_or_default(); - sender.send(Msg::ClipboardPasteToInput { text }).ok(); - }, - ); - } - self.draw_needed.set(true); - return; } else { + // Ctrl+V no longer reaches here: quadraui's runner + // intercepts it and delivers `UiEvent::ClipboardPaste` + // straight to `ShellApp::handle`, which routes through + // `Engine::route_paste` (covers the search/replace + // fields too) before any key event is dispatched (#593). engine.dispatch_search_sidebar_key_unified(mapped, ctrl, alt, unicode); } let still_focused = engine.search_has_focus; @@ -8034,10 +7997,6 @@ impl App { drop(engine); self.draw_needed.set(true); } - Msg::ClipboardPasteToInput { text } => { - self.engine.borrow_mut().route_paste(&text); - self.draw_needed.set(true); - } Msg::WindowClosing { width, height } => { let mut engine = self.engine.borrow_mut(); engine.session.window.width = width; @@ -10300,6 +10259,19 @@ impl quadraui::ShellApp for App { UiEvent::WindowClose => { self.dispatch(Msg::ShowQuitConfirm); } + // #593: quadraui's runner reads the system clipboard on Ctrl+V / + // Ctrl+Shift+V / middle-click and delivers the text here, + // unconditionally consuming the key — there is no raw KeyPressed + // fallback to catch a paste with. `Engine::route_paste` is the + // same focus-priority router TUI's `UiEvent::ClipboardPaste` arm + // already calls (`tui_main/shell_app.rs`), so this one arm covers + // the command line, search/replace fields, explorer rename, and + // the editor buffer — see that fn's doc for the full priority + // chain. + UiEvent::ClipboardPaste(text) => { + self.engine.borrow_mut().route_paste(&text); + self.draw_needed.set(true); + } _ => {} } diff --git a/src/gtk/testing.rs b/src/gtk/testing.rs index 7e5ca800..a6f737e3 100644 --- a/src/gtk/testing.rs +++ b/src/gtk/testing.rs @@ -4722,3 +4722,50 @@ mod app_icon { ); } } + +#[cfg(test)] +mod clipboard_paste { + use super::*; + use quadraui::UiEvent; + + /// #593: `Ctrl+V` did nothing on GTK. quadraui's runner reads the system + /// clipboard and delivers `UiEvent::ClipboardPaste` straight to + /// `ShellApp::handle`, unconditionally consuming the keypress — there is + /// no raw `KeyPressed` fallback for an app that ignores it. Before this + /// fix, `handle`'s catch-all `_` arm swallowed the event and the paste + /// vanished. Mirrors TUI's + /// `bracketed_paste_reaches_the_buffer_via_shell_app` + /// (`tui_main/shell_app.rs`), which covers the same `Engine::route_paste` + /// entry point from the other backend. + /// + /// Command line chosen as the black-box target (rather than the editor + /// buffer) because the GTK backend does not `record_painted_text` editor + /// text — see this module's doc comment — so a buffer paste has no + /// painted pixels this harness can assert against. The command line + /// paints through `Surface::CommandLine`, a quadraui primitive whose text + /// the paint-time recording sink *does* capture (confirmed by the + /// harness smoke test's `"EXPLORER"` assertion against the same sink). + /// `route_paste`'s other destinations (search/replace fields, explorer + /// rename, editor buffer) share this one dispatch arm and are covered at + /// the engine level instead: `search_input_paste` already has coverage, + /// and #593 adds `test_explorer_rename_route_paste` + /// (`core/engine/tests.rs`) for the tree inline-edit target this same + /// fix newly wires up. + #[test] + fn ctrl_v_paste_reaches_the_command_line_via_shell_app() { + let mut engine = Engine::new_for_test(); + engine.mode = crate::core::Mode::Command; + let mut h = harness(engine, 1200, 800); + + h.driver + .dispatch(UiEvent::ClipboardPaste("ZQXW_PASTE_MARKER".to_string())); + h.driver.render(); + + assert!( + h.driver.screen_contains(":ZQXW_PASTE_MARKER"), + "UiEvent::ClipboardPaste must route through Engine::route_paste \ + into the command line; painted texts were {:?}", + h.driver.painted_texts() + ); + } +}