Large clipboard handling overhaul (#405)

* Make each paste its own undo step.
* Add a `Paste` input type, allowing us to...
* Fill the internal clipboard with bracketed paste contents.
* Abstract away clipboard handling into its own struct, so we can move
  the cut/copy/paste logic into `TextBuffer`, allowing us to...
* Implement smart line-wise copy/paste via Ctrl+C/Ctrl+V.

Closes #286
Closes #305
This commit is contained in:
Leonard Hecker 2025-06-10 19:49:27 +02:00 committed by GitHub
parent a36fbe322d
commit c5d91f301e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 210 additions and 120 deletions

View file

@ -37,7 +37,7 @@ fn bench_buffer(c: &mut Criterion) {
{
let mut tb = buffer::TextBuffer::new(false).unwrap();
tb.set_crlf(false);
tb.write(data.start_content.as_bytes(), true);
tb.write_raw(data.start_content.as_bytes());
for t in &data.txns {
for p in &t.patches {
@ -46,7 +46,7 @@ fn bench_buffer(c: &mut Criterion) {
tb.delete(buffer::CursorMovement::Grapheme, p.1 as CoordType);
tb.write(p.2.as_bytes(), true);
tb.write_raw(p.2.as_bytes());
patches_with_coords.push((beg, p.1 as CoordType, p.2.clone()));
}
}
@ -72,12 +72,12 @@ fn bench_buffer(c: &mut Criterion) {
let bench_text_buffer = || {
let mut tb = buffer::TextBuffer::new(false).unwrap();
tb.set_crlf(false);
tb.write(data.start_content.as_bytes(), true);
tb.write_raw(data.start_content.as_bytes());
for p in &patches_with_coords {
tb.cursor_move_to_logical(p.0);
tb.delete(buffer::CursorMovement::Grapheme, p.1);
tb.write(p.2.as_bytes(), true);
tb.write_raw(p.2.as_bytes());
}
tb

View file

@ -74,13 +74,15 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) {
ctx.needs_rerender();
}
if ctx.menubar_menu_button(loc(LocId::EditCut), 'T', kbmod::CTRL | vk::X) {
ctx.set_clipboard(tb.extract_selection(true));
tb.cut(ctx.clipboard_mut());
ctx.needs_rerender();
}
if ctx.menubar_menu_button(loc(LocId::EditCopy), 'C', kbmod::CTRL | vk::C) {
ctx.set_clipboard(tb.extract_selection(false));
tb.copy(ctx.clipboard_mut());
ctx.needs_rerender();
}
if ctx.menubar_menu_button(loc(LocId::EditPaste), 'P', kbmod::CTRL | vk::V) {
tb.write(ctx.clipboard(), true);
tb.paste(ctx.clipboard_ref());
ctx.needs_rerender();
}
if state.wants_search.kind != StateSearchKind::Disabled {

View file

@ -176,8 +176,8 @@ fn run() -> apperr::Result<()> {
}
}
if state.osc_clipboard_send_generation == tui.clipboard_generation() {
write_osc_clipboard(&mut output, &mut state, &tui);
if state.osc_clipboard_sync {
write_osc_clipboard(&mut tui, &mut state, &mut output);
}
#[cfg(feature = "debug-latency")]
@ -317,7 +317,7 @@ fn draw(ctx: &mut Context, state: &mut State) {
if state.wants_about {
draw_dialog_about(ctx, state);
}
if state.osc_clipboard_seen_generation != ctx.clipboard_generation() {
if ctx.clipboard_ref().wants_host_sync() {
draw_handle_clipboard_change(ctx, state);
}
if state.error_log_count != 0 {
@ -389,18 +389,19 @@ fn write_terminal_title(output: &mut ArenaString, filename: &str) {
output.push_str("edit\x1b\\");
}
const LARGE_CLIPBOARD_THRESHOLD: usize = 4 * KIBI;
const LARGE_CLIPBOARD_THRESHOLD: usize = 128 * KIBI;
fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
let generation = ctx.clipboard_generation();
let data_len = ctx.clipboard_ref().read().len();
if state.osc_clipboard_always_send || ctx.clipboard().len() < LARGE_CLIPBOARD_THRESHOLD {
state.osc_clipboard_seen_generation = generation;
state.osc_clipboard_send_generation = generation;
if state.osc_clipboard_always_send || data_len < LARGE_CLIPBOARD_THRESHOLD {
ctx.clipboard_mut().mark_as_synchronized();
state.osc_clipboard_sync = true;
return;
}
let over_limit = ctx.clipboard().len() >= SCRATCH_ARENA_CAPACITY / 4;
let over_limit = data_len >= SCRATCH_ARENA_CAPACITY / 4;
let mut done = None;
ctx.modal_begin("warning", loc(LocId::WarningDialogTitle));
{
@ -415,7 +416,7 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
} else {
let label2 = {
let template = loc(LocId::LargeClipboardWarningLine2);
let size = arena_format!(ctx.arena(), "{}", MetricFormatter(ctx.clipboard().len()));
let size = arena_format!(ctx.arena(), "{}", MetricFormatter(data_len));
let mut label =
ArenaString::with_capacity_in(template.len() + size.len(), ctx.arena());
@ -444,28 +445,26 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
if over_limit {
if ctx.button("ok", loc(LocId::Ok), ButtonStyle::default()) {
state.osc_clipboard_seen_generation = generation;
done = Some(true);
}
ctx.inherit_focus();
} else {
if ctx.button("always", loc(LocId::Always), ButtonStyle::default()) {
state.osc_clipboard_always_send = true;
state.osc_clipboard_seen_generation = generation;
state.osc_clipboard_send_generation = generation;
done = Some(true);
}
if ctx.button("yes", loc(LocId::Yes), ButtonStyle::default()) {
state.osc_clipboard_seen_generation = generation;
state.osc_clipboard_send_generation = generation;
done = Some(true);
}
if ctx.clipboard().len() < 10 * LARGE_CLIPBOARD_THRESHOLD {
if data_len < 10 * LARGE_CLIPBOARD_THRESHOLD {
ctx.inherit_focus();
}
if ctx.button("no", loc(LocId::No), ButtonStyle::default()) {
state.osc_clipboard_seen_generation = generation;
done = Some(false);
}
if ctx.clipboard().len() >= 10 * LARGE_CLIPBOARD_THRESHOLD {
if data_len >= 10 * LARGE_CLIPBOARD_THRESHOLD {
ctx.inherit_focus();
}
}
@ -473,24 +472,33 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
ctx.table_end();
}
if ctx.modal_end() {
state.osc_clipboard_seen_generation = generation;
done = Some(false);
}
if let Some(sync) = done {
state.osc_clipboard_sync = sync;
ctx.clipboard_mut().mark_as_synchronized();
ctx.needs_rerender();
}
}
#[cold]
fn write_osc_clipboard(output: &mut ArenaString, state: &mut State, tui: &Tui) {
let clipboard = tui.clipboard();
if !clipboard.is_empty() {
fn write_osc_clipboard(tui: &mut Tui, state: &mut State, output: &mut ArenaString) {
let clipboard = tui.clipboard_mut();
let data = clipboard.read();
if !data.is_empty() {
// Rust doubles the size of a string when it needs to grow it.
// If `clipboard` is *really* large, this may then double
// If `data` is *really* large, this may then double
// the size of the `output` from e.g. 100MB to 200MB. Not good.
// We can avoid that by reserving the needed size in advance.
output.reserve_exact(base64::encode_len(clipboard.len()) + 16);
output.reserve_exact(base64::encode_len(data.len()) + 16);
output.push_str("\x1b]52;c;");
base64::encode(output, clipboard);
base64::encode(output, data);
output.push_str("\x1b\\");
}
state.osc_clipboard_send_generation = tui.clipboard_generation().wrapping_sub(1);
state.osc_clipboard_sync = false;
}
struct RestoreModes;

View file

@ -162,8 +162,7 @@ pub struct State {
pub goto_invalid: bool,
pub osc_title_filename: String,
pub osc_clipboard_seen_generation: u32,
pub osc_clipboard_send_generation: u32,
pub osc_clipboard_sync: bool,
pub osc_clipboard_always_send: bool,
pub exit: bool,
}
@ -211,8 +210,7 @@ impl State {
goto_invalid: false,
osc_title_filename: Default::default(),
osc_clipboard_seen_generation: 0,
osc_clipboard_send_generation: 0,
osc_clipboard_sync: false,
osc_clipboard_always_send: false,
exit: false,
})

View file

@ -38,6 +38,7 @@ pub use gap_buffer::GapBuffer;
use crate::arena::{ArenaString, scratch_arena};
use crate::cell::SemiRefCell;
use crate::clipboard::Clipboard;
use crate::document::{ReadableDocument, WriteableDocument};
use crate::framebuffer::{Framebuffer, IndexedColor};
use crate::helpers::*;
@ -1083,7 +1084,7 @@ impl TextBuffer {
if let (Some(search), Some(..)) = (&mut self.search, &self.selection) {
let search = search.get_mut();
if search.selection_generation == self.selection_generation {
self.write(replacement.as_bytes(), true);
self.write(replacement.as_bytes(), self.cursor, true);
}
}
@ -1106,7 +1107,7 @@ impl TextBuffer {
if !self.has_selection() {
break;
}
self.write(replacement, true);
self.write(replacement, self.cursor, true);
offset = self.cursor.offset;
}
@ -1822,15 +1823,60 @@ impl TextBuffer {
Some(RenderResult { visual_pos_x_max })
}
/// Inserts `text` at the current cursor position.
///
/// If there's a current selection, it will be replaced.
/// The selection is cleared after the call.
pub fn write(&mut self, text: &[u8], raw: bool) {
pub fn cut(&mut self, clipboard: &mut Clipboard) {
self.cut_copy(clipboard, true);
}
pub fn copy(&mut self, clipboard: &mut Clipboard) {
self.cut_copy(clipboard, false);
}
fn cut_copy(&mut self, clipboard: &mut Clipboard, cut: bool) {
let line_copy = !self.has_selection();
let selection = self.extract_selection(cut);
clipboard.write(selection);
clipboard.write_was_line_copy(line_copy);
}
pub fn paste(&mut self, clipboard: &Clipboard) {
let data = clipboard.read();
if data.is_empty() {
return;
}
let pos = self.cursor_logical_pos();
let at = if clipboard.is_line_copy() {
self.goto_line_start(self.cursor, pos.y)
} else {
self.cursor
};
self.write(data, at, true);
if clipboard.is_line_copy() {
self.cursor_move_to_logical(Point { x: pos.x, y: pos.y + 1 });
}
}
/// Inserts the user input `text` at the current cursor position.
/// Replaces tabs with whitespace if needed, etc.
pub fn write_canon(&mut self, text: &[u8]) {
self.write(text, self.cursor, false);
}
/// Inserts `text` as-is at the current cursor position.
/// The only transformation applied is that newlines are normalized.
pub fn write_raw(&mut self, text: &[u8]) {
self.write(text, self.cursor, true);
}
fn write(&mut self, text: &[u8], at: Cursor, raw: bool) {
let history_type = if raw { HistoryType::Other } else { HistoryType::Write };
// If we have an active selection, writing an empty `text`
// will still delete the selection. As such, we check this first.
if let Some((beg, end)) = self.selection_range_internal(false) {
self.edit_begin(HistoryType::Write, beg);
self.edit_begin(history_type, beg);
self.edit_delete(end);
self.set_selection(None);
}
@ -1846,7 +1892,7 @@ impl TextBuffer {
}
if self.active_edit_depth <= 0 {
self.edit_begin(HistoryType::Write, self.cursor);
self.edit_begin(history_type, at);
}
let mut offset = 0;
@ -2125,7 +2171,8 @@ impl TextBuffer {
/// Extracts the contents of the current selection.
/// May optionally delete it, if requested. This is meant to be used for Ctrl+X.
pub fn extract_selection(&mut self, delete: bool) -> Vec<u8> {
fn extract_selection(&mut self, delete: bool) -> Vec<u8> {
let line_copy = !self.has_selection();
let Some((beg, end)) = self.selection_range_internal(true) else {
return Vec::new();
};
@ -2140,6 +2187,11 @@ impl TextBuffer {
self.set_selection(None);
}
// Line copies (= Ctrl+C when there's no selection) always end with a newline.
if line_copy && !out.ends_with(b"\n") {
out.replace_range(out.len().., if self.newlines_are_crlf { b"\r\n" } else { b"\n" });
}
out
}

53
src/clipboard.rs Normal file
View file

@ -0,0 +1,53 @@
//! Clipboard facilities for the editor.
/// The builtin, internal clipboard of the editor.
///
/// This is useful particularly when the terminal doesn't support
/// OSC 52 or when the clipboard contents are huge (e.g. 1GiB).
#[derive(Default)]
pub struct Clipboard {
data: Vec<u8>,
line_copy: bool,
wants_host_sync: bool,
}
impl Clipboard {
/// If true, we should emit a OSC 52 sequence to sync the clipboard
/// with the hosting terminal.
pub fn wants_host_sync(&self) -> bool {
self.wants_host_sync
}
/// Call this once the clipboard has been synchronized with the host.
pub fn mark_as_synchronized(&mut self) {
self.wants_host_sync = false;
}
/// The editor has a special behavior when you have no selection and press
/// Ctrl+C: It copies the current line to the clipboard. Then, when you
/// paste it, it inserts the line at *the start* of the current line.
/// This effectively prepends the current line with the copied line.
/// `clipboard_line_start` is true in that case.
pub fn is_line_copy(&self) -> bool {
self.line_copy
}
/// Returns the current contents of the clipboard.
pub fn read(&self) -> &[u8] {
&self.data
}
/// Fill the clipboard with the given data.
pub fn write(&mut self, data: Vec<u8>) {
if !data.is_empty() {
self.data = data;
self.line_copy = false;
self.wants_host_sync = true;
}
}
/// See [`Clipboard::is_line_copy`].
pub fn write_was_line_copy(&mut self, line_copy: bool) {
self.line_copy = line_copy;
}
}

View file

@ -6,6 +6,8 @@
//! In the future this allows us to take apart the application and
//! support input schemes that aren't VT, such as UEFI, or GUI.
use std::mem;
use crate::helpers::{CoordType, Point, Size};
use crate::vt;
@ -217,16 +219,6 @@ pub mod kbmod {
pub const CTRL_ALT_SHIFT: InputKeyMod = InputKeyMod::new(0x07000000);
}
/// Text input.
///
/// "Keyboard" input is also "text" input and vice versa.
/// It differs in that text input can also be Unicode.
#[derive(Clone, Copy)]
pub struct InputText<'a> {
pub text: &'a str,
pub bracketed: bool,
}
/// Mouse input state. Up/Down, Left/Right, etc.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum InputMouseState {
@ -261,9 +253,10 @@ pub enum Input<'input> {
/// Window resize event.
Resize(Size),
/// Text input.
///
/// Note that [`Input::Keyboard`] events can also be text.
Text(InputText<'input>),
Text(&'input str),
/// A clipboard paste.
Paste(Vec<u8>),
/// Keyboard input.
Keyboard(InputKey),
/// Mouse input.
@ -273,6 +266,7 @@ pub enum Input<'input> {
/// Parses VT sequences into input events.
pub struct Parser {
bracketed_paste: bool,
bracketed_paste_buf: Vec<u8>,
x10_mouse_want: bool,
x10_mouse_buf: [u8; 3],
x10_mouse_len: usize,
@ -285,6 +279,7 @@ impl Parser {
pub fn new() -> Self {
Self {
bracketed_paste: false,
bracketed_paste_buf: Vec::new(),
x10_mouse_want: false,
x10_mouse_buf: [0; 3],
x10_mouse_len: 0,
@ -333,7 +328,7 @@ impl<'input> Iterator for Stream<'_, '_, 'input> {
match self.stream.next()? {
vt::Token::Text(text) => {
return Some(Input::Text(InputText { text, bracketed: false }));
return Some(Input::Text(text));
}
vt::Token::Ctrl(ch) => match ch {
'\0' | '\t' | '\r' => return Some(Input::Keyboard(InputKey::new(ch as u32))),
@ -519,8 +514,13 @@ impl<'input> Stream<'_, '_, 'input> {
}
if end != beg {
let input = self.stream.input();
Some(Input::Text(InputText { text: &input[beg..end], bracketed: true }))
self.parser
.bracketed_paste_buf
.extend_from_slice(&self.stream.input().as_bytes()[beg..end]);
}
if !self.parser.bracketed_paste {
Some(Input::Paste(mem::take(&mut self.parser.bracketed_paste_buf)))
} else {
None
}

View file

@ -20,6 +20,7 @@ pub mod apperr;
pub mod base64;
pub mod buffer;
pub mod cell;
pub mod clipboard;
pub mod document;
pub mod framebuffer;
pub mod fuzzy;

View file

@ -152,6 +152,7 @@ use std::{iter, mem, ptr, time};
use crate::arena::{Arena, ArenaString, scratch_arena};
use crate::buffer::{CursorMovement, RcTextBuffer, TextBuffer, TextBufferCell};
use crate::cell::*;
use crate::clipboard::Clipboard;
use crate::document::WriteableDocument;
use crate::framebuffer::{Attributes, Framebuffer, INDEXED_COLORS_COUNT, IndexedColor};
use crate::hash::*;
@ -167,7 +168,6 @@ const KBMOD_FOR_WORD_NAV: InputKeyMod =
type Input<'input> = input::Input<'input>;
type InputKey = input::InputKey;
type InputMouseState = input::InputMouseState;
type InputText<'input> = input::InputText<'input>;
/// Since [`TextBuffer`] creation and management is expensive,
/// we cache instances of them for reuse between frames.
@ -363,10 +363,7 @@ pub struct Tui {
cached_text_buffers: Vec<CachedTextBuffer>,
/// The clipboard contents.
clipboard: Vec<u8>,
/// A counter that is incremented every time the clipboard changes.
/// Allows for tracking clipboard changes without comparing contents.
clipboard_generation: u32,
clipboard: Clipboard,
settling_have: i32,
settling_want: i32,
@ -416,8 +413,7 @@ impl Tui {
cached_text_buffers: Vec::with_capacity(16),
clipboard: Vec::new(),
clipboard_generation: 0,
clipboard: Default::default(),
settling_have: 0,
settling_want: 0,
@ -490,16 +486,14 @@ impl Tui {
self.framebuffer.contrasted(color)
}
/// Returns the current clipboard contents.
pub fn clipboard(&self) -> &[u8] {
/// Returns the clipboard.
pub fn clipboard_ref(&self) -> &Clipboard {
&self.clipboard
}
/// Returns the current clipboard generation.
/// The generation changes every time the clipboard contents change.
/// This allows you to track clipboard changes.
pub fn clipboard_generation(&self) -> u32 {
self.clipboard_generation
/// Returns the clipboard (mutable).
pub fn clipboard_mut(&mut self) -> &mut Clipboard {
&mut self.clipboard
}
/// Starts a new frame and returns a [`Context`] for it.
@ -553,11 +547,17 @@ impl Tui {
// This causes us to ignore the keyboard input here. We need a way to inform the caller over
// how much of the input text we actually processed in a single frame. Or perhaps we could use
// the needs_settling logic?
if !text.bracketed && text.text.len() == 1 {
let ch = text.text.as_bytes()[0];
if text.len() == 1 {
let ch = text.as_bytes()[0];
input_keyboard = InputKey::from_ascii(ch as char)
}
}
Some(Input::Paste(paste)) => {
let clipboard = self.clipboard_mut();
clipboard.write(paste);
clipboard.mark_as_synchronized();
input_keyboard = Some(kbmod::CTRL | vk::V);
}
Some(Input::Keyboard(keyboard)) => {
input_keyboard = Some(keyboard);
}
@ -1314,7 +1314,7 @@ pub struct Context<'a, 'input> {
tui: &'a mut Tui,
/// Current text input, if any.
input_text: Option<InputText<'input>>,
input_text: Option<&'input str>,
/// Current keyboard input, if any.
input_keyboard: Option<InputKey>,
input_mouse_modifiers: InputKeyMod,
@ -1376,25 +1376,14 @@ impl<'a> Context<'a, '_> {
self.tui.framebuffer.contrasted(color)
}
/// Returns the current clipboard contents.
pub fn clipboard(&self) -> &[u8] {
self.tui.clipboard()
/// Returns the clipboard.
pub fn clipboard_ref(&self) -> &Clipboard {
&self.tui.clipboard
}
/// Returns the current clipboard generation.
/// The generation changes every time the clipboard contents change.
/// This allows you to track clipboard changes.
pub fn clipboard_generation(&self) -> u32 {
self.tui.clipboard_generation()
}
/// Sets the clipboard contents.
pub fn set_clipboard(&mut self, data: Vec<u8>) {
if !data.is_empty() {
self.tui.clipboard = data;
self.tui.clipboard_generation = self.tui.clipboard_generation.wrapping_add(1);
self.needs_rerender();
}
/// Returns the clipboard (mutable).
pub fn clipboard_mut(&mut self) -> &mut Clipboard {
&mut self.tui.clipboard
}
/// Tell the UI framework that your state changed and you need another layout pass.
@ -2052,11 +2041,7 @@ impl<'a> Context<'a, '_> {
/// Creates a text input field.
/// Returns true if the text contents changed.
pub fn editline<'s, 'b: 's>(
&'s mut self,
classname: &'static str,
text: &'b mut dyn WriteableDocument,
) -> bool {
pub fn editline(&mut self, classname: &'static str, text: &mut dyn WriteableDocument) -> bool {
self.textarea_internal(classname, TextBufferPayload::Editline(text))
}
@ -2322,14 +2307,10 @@ impl<'a> Context<'a, '_> {
return false;
}
let mut write: &[u8] = b"";
let mut write_raw = false;
let mut write: &[u8] = &[];
if let Some(input) = &self.input_text {
write = input.text.as_bytes();
write_raw = input.bracketed;
tc.preferred_column = tb.cursor_visual_pos().x;
make_cursor_visible = true;
write = input.as_bytes();
} else if let Some(input) = &self.input_keyboard {
let key = input.key();
let modifiers = input.modifiers();
@ -2643,15 +2624,12 @@ impl<'a> Context<'a, '_> {
}
}
vk::INSERT => match modifiers {
kbmod::SHIFT => {
write = &self.tui.clipboard;
write_raw = true;
}
kbmod::CTRL => self.set_clipboard(tb.extract_selection(false)),
kbmod::SHIFT => tb.paste(self.clipboard_ref()),
kbmod::CTRL => tb.copy(self.clipboard_mut()),
_ => tb.set_overtype(!tb.is_overtype()),
},
vk::DELETE => match modifiers {
kbmod::SHIFT => self.set_clipboard(tb.extract_selection(true)),
kbmod::SHIFT => tb.cut(self.clipboard_mut()),
kbmod::CTRL => tb.delete(CursorMovement::Word, 1),
_ => tb.delete(CursorMovement::Grapheme, 1),
},
@ -2680,18 +2658,15 @@ impl<'a> Context<'a, '_> {
_ => return false,
},
vk::X => match modifiers {
kbmod::CTRL => self.set_clipboard(tb.extract_selection(true)),
kbmod::CTRL => tb.cut(self.clipboard_mut()),
_ => return false,
},
vk::C => match modifiers {
kbmod::CTRL => self.set_clipboard(tb.extract_selection(false)),
kbmod::CTRL => tb.copy(self.clipboard_mut()),
_ => return false,
},
vk::V => match modifiers {
kbmod::CTRL => {
write = &self.tui.clipboard;
write_raw = true;
}
kbmod::CTRL => tb.paste(self.clipboard_ref()),
_ => return false,
},
vk::Y => match modifiers {
@ -2717,8 +2692,9 @@ impl<'a> Context<'a, '_> {
write = unicode::strip_newline(&write[..end]);
}
if !write.is_empty() {
tb.write(write, write_raw);
tb.write_canon(write);
change_preferred_column = true;
make_cursor_visible = true;
}
if change_preferred_column {