Skip to content

Large clipboard handling overhaul #405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 4 additions & 4 deletions benches/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()));
}
}
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/bin/edit/draw_menubar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,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 {
Expand Down
60 changes: 34 additions & 26 deletions src/bin/edit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,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")]
Expand Down Expand Up @@ -323,7 +323,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 {
Expand Down Expand Up @@ -393,18 +393,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));
{
Expand All @@ -419,7 +420,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());
Expand Down Expand Up @@ -448,53 +449,60 @@ 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();
}
}
}
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;
Expand Down
6 changes: 2 additions & 4 deletions src/bin/edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,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,
}
Expand Down Expand Up @@ -201,8 +200,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,
})
Expand Down
71 changes: 61 additions & 10 deletions src/buffer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -1070,7 +1071,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);
}
}

Expand All @@ -1093,7 +1094,7 @@ impl TextBuffer {
if !self.has_selection() {
break;
}
self.write(replacement, true);
self.write(replacement, self.cursor, true);
offset = self.cursor.offset;
}

Expand Down Expand Up @@ -1790,22 +1791,66 @@ 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) {
if text.is_empty() {
return;
}

let history_type = if raw { HistoryType::Other } else { HistoryType::Write };
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);
}
if self.active_edit_depth <= 0 {
self.edit_begin(HistoryType::Write, self.cursor);
self.edit_begin(history_type, at);
}

let mut offset = 0;
Expand Down Expand Up @@ -2084,7 +2129,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();
};
Expand All @@ -2099,6 +2145,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
}

Expand Down
53 changes: 53 additions & 0 deletions src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading