Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 96 additions & 3 deletions src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use crossterm::event::KeyEvent;
use crossterm::event::{KeyCode, KeyEvent};
use edtui::actions::{
Composed, Execute, SwitchMode, cpaste::Paste, insert::InsertChar, motion::MoveBackward,
};
use edtui::{EditorEventHandler, EditorMode, EditorState, Index2, Lines};

/// A file-backed edtui buffer: vim editing plus load/save and a modified flag.
Expand Down Expand Up @@ -59,12 +62,23 @@ impl EditorBuffer {
}

pub fn handle_key(&mut self, key: KeyEvent) {
self.handler.on_key_event(key, &mut self.state);
if self.state.mode == EditorMode::Normal
&& key.code == KeyCode::Char('p')
&& key.modifiers.is_empty()
{
self.paste_yanked_at_cursor();
} else {
self.handler.on_key_event(key, &mut self.state);
}
self.refresh_modified();
}

pub fn handle_paste(&mut self, text: String) {
self.handler.on_paste_event(text, &mut self.state);
if self.state.mode == EditorMode::Normal {
self.paste_text_at_cursor(text);
} else {
self.handler.on_paste_event(text, &mut self.state);
}
self.refresh_modified();
}

Expand Down Expand Up @@ -102,6 +116,45 @@ impl EditorBuffer {
self.handle_paste(text.to_string());
}

/// Paste the yank register at the cursor, not after it (edtui's default `p`).
fn paste_yanked_at_cursor(&mut self) {
// edtui's `Paste` inserts after the cursor; in insert mode, stepping back
// one column first lands the insertion at the normal-mode cursor.
if self.state.cursor.col == 0 && self.cursor_line_has_text() {
self.paste_text_at_cursor(self.clipboard_text());
return;
}
let mut action = Composed::new(SwitchMode(EditorMode::Insert));
if self.state.cursor.col > 0 {
action = action.chain(MoveBackward(1));
}
action = action.chain(Paste).chain(SwitchMode(EditorMode::Normal));
action.execute(&mut self.state);
}

fn cursor_line_has_text(&self) -> bool {
self.state
.lines
.len_col(self.state.cursor.row)
.is_some_and(|len| len > 0)
}

fn clipboard_text(&self) -> String {
arboard::Clipboard::new()
.and_then(|mut clip| clip.get_text())
.unwrap_or_default()
}

/// Paste arbitrary text at the cursor in normal mode (bracketed paste).
fn paste_text_at_cursor(&mut self, text: String) {
let restore = self.state.mode;
self.state.execute(SwitchMode(EditorMode::Insert));
for ch in text.chars() {
self.state.execute(InsertChar(ch));
}
self.state.execute(SwitchMode(restore));
}

fn refresh_modified(&mut self) {
// .http files are small; hashing the buffer per keystroke is cheap
// and beats trying to track dirtiness through edtui's undo stack.
Expand All @@ -117,3 +170,43 @@ fn hash_str(s: &str) -> u64 {
s.hash(&mut h);
h.finish()
}

#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

fn test_buffer(text: &str) -> EditorBuffer {
let path =
std::env::temp_dir().join(format!("req-editor-test-{}.http", std::process::id()));
std::fs::write(&path, text).unwrap();
EditorBuffer::open(path).unwrap()
}

fn set_system_clipboard(text: &str) {
arboard::Clipboard::new()
.and_then(|mut clip| clip.set_text(text.to_string()))
.unwrap();
}

#[test]
fn normal_p_pastes_at_cursor_not_after() {
let mut buf = test_buffer("Hello World");
buf.state.cursor = Index2::new(0, 5);
set_system_clipboard("X");

buf.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));

assert_eq!(buf.text(), "HelloX World");
}

#[test]
fn normal_bracketed_paste_inserts_at_cursor() {
let mut buf = test_buffer("Hello World");
buf.state.cursor = Index2::new(0, 5);

buf.handle_paste("X".to_string());

assert_eq!(buf.text(), "HelloX World");
}
}