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(); let mut tb = buffer::TextBuffer::new(false).unwrap();
tb.set_crlf(false); 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 t in &data.txns {
for p in &t.patches { 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.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())); 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 bench_text_buffer = || {
let mut tb = buffer::TextBuffer::new(false).unwrap(); let mut tb = buffer::TextBuffer::new(false).unwrap();
tb.set_crlf(false); 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 { for p in &patches_with_coords {
tb.cursor_move_to_logical(p.0); tb.cursor_move_to_logical(p.0);
tb.delete(buffer::CursorMovement::Grapheme, p.1); tb.delete(buffer::CursorMovement::Grapheme, p.1);
tb.write(p.2.as_bytes(), true); tb.write_raw(p.2.as_bytes());
} }
tb tb

View file

@ -74,13 +74,15 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) {
ctx.needs_rerender(); ctx.needs_rerender();
} }
if ctx.menubar_menu_button(loc(LocId::EditCut), 'T', kbmod::CTRL | vk::X) { 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) { 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) { 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(); ctx.needs_rerender();
} }
if state.wants_search.kind != StateSearchKind::Disabled { 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() { if state.osc_clipboard_sync {
write_osc_clipboard(&mut output, &mut state, &tui); write_osc_clipboard(&mut tui, &mut state, &mut output);
} }
#[cfg(feature = "debug-latency")] #[cfg(feature = "debug-latency")]
@ -317,7 +317,7 @@ fn draw(ctx: &mut Context, state: &mut State) {
if state.wants_about { if state.wants_about {
draw_dialog_about(ctx, state); 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); draw_handle_clipboard_change(ctx, state);
} }
if state.error_log_count != 0 { if state.error_log_count != 0 {
@ -389,18 +389,19 @@ fn write_terminal_title(output: &mut ArenaString, filename: &str) {
output.push_str("edit\x1b\\"); 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) { 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 { if state.osc_clipboard_always_send || data_len < LARGE_CLIPBOARD_THRESHOLD {
state.osc_clipboard_seen_generation = generation; ctx.clipboard_mut().mark_as_synchronized();
state.osc_clipboard_send_generation = generation; state.osc_clipboard_sync = true;
return; 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)); ctx.modal_begin("warning", loc(LocId::WarningDialogTitle));
{ {
@ -415,7 +416,7 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
} else { } else {
let label2 = { let label2 = {
let template = loc(LocId::LargeClipboardWarningLine2); 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 = let mut label =
ArenaString::with_capacity_in(template.len() + size.len(), ctx.arena()); 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 over_limit {
if ctx.button("ok", loc(LocId::Ok), ButtonStyle::default()) { if ctx.button("ok", loc(LocId::Ok), ButtonStyle::default()) {
state.osc_clipboard_seen_generation = generation; done = Some(true);
} }
ctx.inherit_focus(); ctx.inherit_focus();
} else { } else {
if ctx.button("always", loc(LocId::Always), ButtonStyle::default()) { if ctx.button("always", loc(LocId::Always), ButtonStyle::default()) {
state.osc_clipboard_always_send = true; state.osc_clipboard_always_send = true;
state.osc_clipboard_seen_generation = generation; done = Some(true);
state.osc_clipboard_send_generation = generation;
} }
if ctx.button("yes", loc(LocId::Yes), ButtonStyle::default()) { if ctx.button("yes", loc(LocId::Yes), ButtonStyle::default()) {
state.osc_clipboard_seen_generation = generation; done = Some(true);
state.osc_clipboard_send_generation = generation;
} }
if ctx.clipboard().len() < 10 * LARGE_CLIPBOARD_THRESHOLD { if data_len < 10 * LARGE_CLIPBOARD_THRESHOLD {
ctx.inherit_focus(); ctx.inherit_focus();
} }
if ctx.button("no", loc(LocId::No), ButtonStyle::default()) { 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.inherit_focus();
} }
} }
@ -473,24 +472,33 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) {
ctx.table_end(); ctx.table_end();
} }
if ctx.modal_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] #[cold]
fn write_osc_clipboard(output: &mut ArenaString, state: &mut State, tui: &Tui) { fn write_osc_clipboard(tui: &mut Tui, state: &mut State, output: &mut ArenaString) {
let clipboard = tui.clipboard(); let clipboard = tui.clipboard_mut();
if !clipboard.is_empty() { let data = clipboard.read();
if !data.is_empty() {
// Rust doubles the size of a string when it needs to grow it. // 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. // the size of the `output` from e.g. 100MB to 200MB. Not good.
// We can avoid that by reserving the needed size in advance. // 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;"); output.push_str("\x1b]52;c;");
base64::encode(output, clipboard); base64::encode(output, data);
output.push_str("\x1b\\"); output.push_str("\x1b\\");
} }
state.osc_clipboard_send_generation = tui.clipboard_generation().wrapping_sub(1);
state.osc_clipboard_sync = false;
} }
struct RestoreModes; struct RestoreModes;

View file

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

View file

@ -38,6 +38,7 @@ pub use gap_buffer::GapBuffer;
use crate::arena::{ArenaString, scratch_arena}; use crate::arena::{ArenaString, scratch_arena};
use crate::cell::SemiRefCell; use crate::cell::SemiRefCell;
use crate::clipboard::Clipboard;
use crate::document::{ReadableDocument, WriteableDocument}; use crate::document::{ReadableDocument, WriteableDocument};
use crate::framebuffer::{Framebuffer, IndexedColor}; use crate::framebuffer::{Framebuffer, IndexedColor};
use crate::helpers::*; use crate::helpers::*;
@ -1083,7 +1084,7 @@ impl TextBuffer {
if let (Some(search), Some(..)) = (&mut self.search, &self.selection) { if let (Some(search), Some(..)) = (&mut self.search, &self.selection) {
let search = search.get_mut(); let search = search.get_mut();
if search.selection_generation == self.selection_generation { 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() { if !self.has_selection() {
break; break;
} }
self.write(replacement, true); self.write(replacement, self.cursor, true);
offset = self.cursor.offset; offset = self.cursor.offset;
} }
@ -1822,15 +1823,60 @@ impl TextBuffer {
Some(RenderResult { visual_pos_x_max }) Some(RenderResult { visual_pos_x_max })
} }
/// Inserts `text` at the current cursor position. pub fn cut(&mut self, clipboard: &mut Clipboard) {
/// self.cut_copy(clipboard, true);
/// 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 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` // If we have an active selection, writing an empty `text`
// will still delete the selection. As such, we check this first. // will still delete the selection. As such, we check this first.
if let Some((beg, end)) = self.selection_range_internal(false) { 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.edit_delete(end);
self.set_selection(None); self.set_selection(None);
} }
@ -1846,7 +1892,7 @@ impl TextBuffer {
} }
if self.active_edit_depth <= 0 { if self.active_edit_depth <= 0 {
self.edit_begin(HistoryType::Write, self.cursor); self.edit_begin(history_type, at);
} }
let mut offset = 0; let mut offset = 0;
@ -2125,7 +2171,8 @@ impl TextBuffer {
/// Extracts the contents of the current selection. /// Extracts the contents of the current selection.
/// May optionally delete it, if requested. This is meant to be used for Ctrl+X. /// 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 { let Some((beg, end)) = self.selection_range_internal(true) else {
return Vec::new(); return Vec::new();
}; };
@ -2140,6 +2187,11 @@ impl TextBuffer {
self.set_selection(None); 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 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 //! In the future this allows us to take apart the application and
//! support input schemes that aren't VT, such as UEFI, or GUI. //! support input schemes that aren't VT, such as UEFI, or GUI.
use std::mem;
use crate::helpers::{CoordType, Point, Size}; use crate::helpers::{CoordType, Point, Size};
use crate::vt; use crate::vt;
@ -217,16 +219,6 @@ pub mod kbmod {
pub const CTRL_ALT_SHIFT: InputKeyMod = InputKeyMod::new(0x07000000); 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. /// Mouse input state. Up/Down, Left/Right, etc.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum InputMouseState { pub enum InputMouseState {
@ -261,9 +253,10 @@ pub enum Input<'input> {
/// Window resize event. /// Window resize event.
Resize(Size), Resize(Size),
/// Text input. /// Text input.
///
/// Note that [`Input::Keyboard`] events can also be text. /// Note that [`Input::Keyboard`] events can also be text.
Text(InputText<'input>), Text(&'input str),
/// A clipboard paste.
Paste(Vec<u8>),
/// Keyboard input. /// Keyboard input.
Keyboard(InputKey), Keyboard(InputKey),
/// Mouse input. /// Mouse input.
@ -273,6 +266,7 @@ pub enum Input<'input> {
/// Parses VT sequences into input events. /// Parses VT sequences into input events.
pub struct Parser { pub struct Parser {
bracketed_paste: bool, bracketed_paste: bool,
bracketed_paste_buf: Vec<u8>,
x10_mouse_want: bool, x10_mouse_want: bool,
x10_mouse_buf: [u8; 3], x10_mouse_buf: [u8; 3],
x10_mouse_len: usize, x10_mouse_len: usize,
@ -285,6 +279,7 @@ impl Parser {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
bracketed_paste: false, bracketed_paste: false,
bracketed_paste_buf: Vec::new(),
x10_mouse_want: false, x10_mouse_want: false,
x10_mouse_buf: [0; 3], x10_mouse_buf: [0; 3],
x10_mouse_len: 0, x10_mouse_len: 0,
@ -333,7 +328,7 @@ impl<'input> Iterator for Stream<'_, '_, 'input> {
match self.stream.next()? { match self.stream.next()? {
vt::Token::Text(text) => { vt::Token::Text(text) => {
return Some(Input::Text(InputText { text, bracketed: false })); return Some(Input::Text(text));
} }
vt::Token::Ctrl(ch) => match ch { vt::Token::Ctrl(ch) => match ch {
'\0' | '\t' | '\r' => return Some(Input::Keyboard(InputKey::new(ch as u32))), '\0' | '\t' | '\r' => return Some(Input::Keyboard(InputKey::new(ch as u32))),
@ -519,8 +514,13 @@ impl<'input> Stream<'_, '_, 'input> {
} }
if end != beg { if end != beg {
let input = self.stream.input(); self.parser
Some(Input::Text(InputText { text: &input[beg..end], bracketed: true })) .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 { } else {
None None
} }

View file

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

View file

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