Skip to content

Commit eae4299

Browse files
committed
feat: handle bracketed paste and batch-insert pasted text (input + script editor)
1 parent cd0b64a commit eae4299

3 files changed

Lines changed: 125 additions & 5 deletions

File tree

ghostscope-ui/src/components/app.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use crate::model::ui_state::LayoutMode;
55
use crate::model::AppState;
66
use anyhow::Result;
77
use crossterm::{
8-
event::{Event, EventStream, KeyCode, KeyEventKind},
8+
event::{
9+
DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyEventKind,
10+
},
911
execute,
1012
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
1113
};
@@ -33,6 +35,8 @@ impl App {
3335
enable_raw_mode()?;
3436
let mut stdout = io::stdout();
3537
execute!(stdout, EnterAlternateScreen)?;
38+
// Enable bracketed paste to detect paste events (does not affect mouse selection copy)
39+
execute!(stdout, EnableBracketedPaste)?;
3640
// Mouse capture disabled to allow standard copy/paste functionality
3741
let backend = CrosstermBackend::new(stdout);
3842
let terminal = Terminal::new(backend)?;
@@ -67,6 +71,8 @@ impl App {
6771
enable_raw_mode()?;
6872
let mut stdout = io::stdout();
6973
execute!(stdout, EnterAlternateScreen)?;
74+
// Enable bracketed paste to detect paste events (does not affect mouse selection copy)
75+
execute!(stdout, EnableBracketedPaste)?;
7076
// Mouse capture disabled to allow standard copy/paste functionality
7177
let backend = CrosstermBackend::new(stdout);
7278
let terminal = Terminal::new(backend)?;
@@ -204,10 +210,6 @@ impl App {
204210

205211
/// Handle terminal events and convert to actions
206212
async fn handle_event(&mut self, event: Event) -> Result<bool> {
207-
// Only log non-mouse events to reduce noise
208-
if !matches!(event, Event::Mouse(_)) {
209-
tracing::debug!("handle_event called with: {:?}", event);
210-
}
211213
let mut actions_to_process = Vec::new();
212214

213215
match event {
@@ -395,6 +397,39 @@ impl App {
395397
Event::Resize(width, height) => {
396398
actions_to_process.push(Action::Resize(width, height));
397399
}
400+
Event::Paste(pasted) => {
401+
tracing::debug!("Event received: paste_len={}", pasted.len());
402+
// Batch insert pasted text depending on focused panel and mode
403+
match self.state.ui.focus.current_panel {
404+
PanelType::InteractiveCommand => {
405+
match self.state.command_panel.mode {
406+
crate::model::panel_state::InteractionMode::Input => {
407+
let actions = self
408+
.state
409+
.command_input_handler
410+
.insert_str(&mut self.state.command_panel, &pasted);
411+
actions_to_process.extend(actions);
412+
self.state.command_renderer.mark_pending_updates();
413+
}
414+
crate::model::panel_state::InteractionMode::ScriptEditor => {
415+
let actions =
416+
crate::components::command_panel::ScriptEditor::insert_text(
417+
&mut self.state.command_panel,
418+
&pasted,
419+
);
420+
actions_to_process.extend(actions);
421+
self.state.command_renderer.mark_pending_updates();
422+
}
423+
crate::model::panel_state::InteractionMode::Command => {
424+
// Ignore paste in command mode
425+
}
426+
}
427+
}
428+
_ => {
429+
// Ignore paste in other panels
430+
}
431+
}
432+
}
398433
_ => {}
399434
}
400435

@@ -3357,6 +3392,8 @@ impl App {
33573392
/// Cleanup terminal
33583393
async fn cleanup(&mut self) -> Result<()> {
33593394
disable_raw_mode()?;
3395+
// Disable bracketed paste before leaving alternate screen
3396+
execute!(self.terminal.backend_mut(), DisableBracketedPaste)?;
33603397
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
33613398
// Mouse capture was not enabled, so no need to disable it
33623399
self.terminal.show_cursor()?;

ghostscope-ui/src/components/command_panel/optimized_input.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,43 @@ impl OptimizedInputHandler {
5757
result
5858
}
5959

60+
/// Insert a full string at the current cursor position efficiently (batch paste)
61+
/// - In Input mode: inserts as single line (newlines converted to spaces), updates suggestion once
62+
/// - In ScriptEditor: delegates to ScriptEditor for multi-line aware insertion
63+
pub fn insert_str(&mut self, state: &mut CommandPanelState, text: &str) -> Vec<Action> {
64+
self.last_input_time = Instant::now();
65+
66+
match state.mode {
67+
InteractionMode::Input => {
68+
// Cancel any pending jk escape
69+
state.jk_escape_state = JkEscapeState::None;
70+
state.jk_timer = None;
71+
72+
// Normalize pasted text to a single line for command input
73+
// Convert CRLF/CR to LF, then LF to space
74+
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
75+
let sanitized = normalized.replace('\n', " ");
76+
if sanitized.is_empty() {
77+
return Vec::new();
78+
}
79+
80+
let byte_pos = self.char_pos_to_byte_pos(&state.input_text, state.cursor_position);
81+
state.input_text.insert_str(byte_pos, &sanitized);
82+
state.cursor_position += sanitized.chars().count();
83+
84+
// Update auto suggestion once after batch insert
85+
state.update_auto_suggestion();
86+
Vec::new()
87+
}
88+
InteractionMode::ScriptEditor => {
89+
// Delegate to script editor for multi-line aware insertion
90+
use crate::components::command_panel::ScriptEditor;
91+
ScriptEditor::insert_text(state, text)
92+
}
93+
InteractionMode::Command => Vec::new(),
94+
}
95+
}
96+
6097
/// Handle character input in Input mode (normal typing)
6198
fn handle_input_mode_char(&mut self, state: &mut CommandPanelState, ch: char) -> Vec<Action> {
6299
// Handle jk escape sequence first

ghostscope-ui/src/components/command_panel/script_editor.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,52 @@ impl ScriptEditor {
195195
Vec::new()
196196
}
197197

198+
/// Insert a string at the cursor position (supports newlines)
199+
pub fn insert_text(state: &mut CommandPanelState, text: &str) -> Vec<Action> {
200+
if let Some(ref mut cache) = state.script_cache {
201+
// Normalize line endings to LF
202+
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
203+
let mut iter = normalized.split('\n');
204+
205+
if cache.cursor_line >= cache.lines.len() {
206+
cache.lines.push(String::new());
207+
cache.cursor_line = cache.lines.len() - 1;
208+
cache.cursor_col = 0;
209+
}
210+
211+
// Insert first segment into current line
212+
if let Some(first) = iter.next() {
213+
let line = &mut cache.lines[cache.cursor_line];
214+
let byte_pos = Self::char_pos_to_byte_pos(line, cache.cursor_col);
215+
line.insert_str(byte_pos, first);
216+
cache.cursor_col += first.chars().count();
217+
}
218+
219+
// Remaining segments: create new lines
220+
let mut is_first_newline = true;
221+
for seg in iter {
222+
// Split current line at cursor once to move the right part down
223+
if is_first_newline {
224+
let byte_pos = Self::char_pos_to_byte_pos(
225+
&cache.lines[cache.cursor_line],
226+
cache.cursor_col,
227+
);
228+
let right = cache.lines[cache.cursor_line].split_off(byte_pos);
229+
let new_line = format!("{seg}{right}");
230+
cache.cursor_line += 1;
231+
cache.lines.insert(cache.cursor_line, new_line);
232+
cache.cursor_col = seg.chars().count();
233+
is_first_newline = false;
234+
} else {
235+
cache.cursor_line += 1;
236+
cache.lines.insert(cache.cursor_line, seg.to_string());
237+
cache.cursor_col = seg.chars().count();
238+
}
239+
}
240+
}
241+
Vec::new()
242+
}
243+
198244
/// Insert a newline at the cursor position
199245
pub fn insert_newline(state: &mut CommandPanelState) -> Vec<Action> {
200246
if let Some(ref mut cache) = state.script_cache {

0 commit comments

Comments
 (0)