Improve highlighting and clipboard paste flow

This commit is contained in:
MihneaTeodorStoica 2025-11-09 10:48:26 +02:00
parent 8ed71f40dc
commit 2f01ef2db2
7 changed files with 588 additions and 19 deletions

View file

@ -7,6 +7,19 @@ use crate::arena::ArenaString;
const CHARSET: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn decode_value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
b'=' => Some(64),
b'\r' | b'\n' => None,
_ => Some(0xff),
}
}
/// One aspect of base64 is that the encoded length can be
/// calculated accurately in advance, which is what this returns.
#[inline]
@ -77,6 +90,63 @@ pub fn encode(dst: &mut ArenaString, src: &[u8]) {
}
}
/// Decodes a base64 string into raw bytes.
pub fn decode(src: &str) -> Option<Vec<u8>> {
let mut chunk = [0u8; 4];
let mut chunk_len = 0;
let mut out = Vec::with_capacity(src.len().saturating_sub(3) / 4 * 3);
for &byte in src.as_bytes() {
let Some(val) = decode_value(byte) else {
continue;
};
if val == 0xff {
return None;
}
chunk[chunk_len] = val;
chunk_len += 1;
if chunk_len == 4 {
if chunk[0] == 64 || chunk[1] == 64 {
return None;
}
out.push((chunk[0] << 2) | (chunk[1] >> 4));
match chunk[2] {
64 => {
if chunk[3] != 64 {
return None;
}
}
val => {
out.push((chunk[1] << 4) | (val >> 2));
}
}
if let (Some(c), Some(d)) =
((chunk[2] != 64).then_some(chunk[2]), (chunk[3] != 64).then_some(chunk[3]))
{
out.push((c << 6) | d);
} else if chunk[3] != 64 {
return None;
}
if chunk[2] == 64 && chunk[3] != 64 {
return None;
}
chunk_len = 0;
}
}
if chunk_len != 0 {
return None;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::encode;
@ -118,4 +188,13 @@ mod tests {
assert_eq!(enc(b"abcdefghijklmNOPQRSTUVWXY"), "YWJjZGVmZ2hpamtsbU5PUFFSU1RVVldYWQ==");
assert_eq!(enc(b"abcdefghijklmNOPQRSTUVWXYZ"), "YWJjZGVmZ2hpamtsbU5PUFFSU1RVVldYWVo=");
}
#[test]
fn roundtrip_decode() {
let arena = Arena::new(4 * 1024).unwrap();
let mut dst = ArenaString::new_in(&arena);
encode(&mut dst, b"hello clipboard");
let decoded = super::decode(&dst).unwrap();
assert_eq!(decoded, b"hello clipboard");
}
}

View file

@ -167,6 +167,10 @@ fn run() -> apperr::Result<()> {
write_osc_clipboard(&mut tui, &mut state, &mut output);
}
if tui.take_clipboard_request() {
output.push_str("\x1b]52;c;?\x1b\\");
}
#[cfg(feature = "debug-latency")]
{
// Print the number of passes and latency in the top right corner.

View file

@ -59,6 +59,7 @@ const VISUAL_SPACE: &str = "・";
const VISUAL_SPACE_PREFIX_ADD: usize = '・'.len_utf8() - 1;
const VISUAL_TAB: &str = "";
const VISUAL_TAB_PREFIX_ADD: usize = '→'.len_utf8() - 1;
const HIGHLIGHT_LEFT_CONTEXT_BYTES: usize = 2048;
/// Stores statistics about the whole document.
#[derive(Copy, Clone)]
@ -1762,6 +1763,7 @@ impl TextBuffer {
};
line.reserve(width as usize * 2);
let highlight_enabled = self.syntax.has_highlighting();
for y in 0..height {
line.clear();
@ -1775,7 +1777,22 @@ impl TextBuffer {
cursor_beg,
Point { x: origin.x + text_width, y: visual_line },
);
let line_offset_base = cursor_beg.offset;
let mut highlight_offset_base = cursor_beg.offset;
let mut highlight_has_visible = false;
if highlight_enabled && cursor_beg.offset > 0 {
let line_start = self.goto_line_start(cursor_beg, cursor_beg.logical_pos.y);
let available = cursor_beg.offset.saturating_sub(line_start.offset);
if available > 0 {
let context_bytes = available.min(HIGHLIGHT_LEFT_CONTEXT_BYTES);
highlight_offset_base = cursor_beg.offset - context_bytes;
self.append_utf8_range(
highlight_offset_base,
cursor_beg.offset,
&mut highlight_line_text,
);
}
}
// Accelerate the next render pass by remembering where we started off.
if y == 0 {
@ -1910,8 +1927,9 @@ impl TextBuffer {
break;
};
if self.syntax.has_highlighting() {
if highlight_enabled {
highlight_line_text.push(ch);
highlight_has_visible = true;
}
if ch == ' ' || ch == '\t' {
@ -1994,11 +2012,11 @@ impl TextBuffer {
visual_pos_x_max = visual_pos_x_max.max(cursor_end.visual_pos.x);
}
if self.syntax.has_highlighting() && !highlight_line_text.is_empty() {
if highlight_enabled && highlight_has_visible {
highlight::highlight_line(self.syntax, &highlight_line_text, &mut highlight_spans);
self.paint_highlight_spans(
cursor_end,
line_offset_base,
highlight_offset_base,
&highlight_spans,
&selection_off,
origin,
@ -3189,6 +3207,23 @@ impl TextBuffer {
pub fn read_forward(&self, off: usize) -> &[u8] {
self.buffer.read_forward(off)
}
fn append_utf8_range(&self, mut start: usize, end: usize, out: &mut ArenaString) {
let end = end.min(self.text_length());
start = start.min(end);
while start < end {
let chunk = self.read_forward(start);
if chunk.is_empty() {
break;
}
let take = (end - start).min(chunk.len());
let mut it = Utf8Chars::new(&chunk[..take], 0);
while let Some(ch) = it.next() {
out.push(ch);
}
start += take;
}
}
}
pub enum Bom {

View file

@ -39,11 +39,9 @@ impl Clipboard {
/// 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;
}
self.line_copy = false;
self.wants_host_sync = !data.is_empty();
self.data = data;
}
/// See [`Clipboard::is_line_copy`].

View file

@ -86,7 +86,7 @@ pub fn highlight_line(kind: SyntaxKind, line: &str, out: &mut Vec<HighlightSpan>
match kind {
SyntaxKind::Plain => {}
SyntaxKind::Rust => highlight_c_like(line, out, RUST_KEYWORDS, RUST_TYPES, true),
SyntaxKind::Rust => highlight_rust(line, out),
SyntaxKind::Cpp => highlight_c_like(line, out, CPP_KEYWORDS, CPP_TYPES, true),
SyntaxKind::Json => highlight_json(line, out),
SyntaxKind::Toml => highlight_toml(line, out),
@ -259,7 +259,7 @@ fn highlight_c_like(
highlight_strings(body, true, true, out);
let occupied = build_occupied(body.len(), out, &[HighlightClass::String]);
highlight_numbers_with_mask(body, Some(&occupied), out);
highlight_numbers_with_mask(body, Some(&occupied), true, out);
highlight_words_with_mask(body, keywords, HighlightClass::Keyword, Some(&occupied), out);
highlight_words_with_mask(body, types, HighlightClass::Type, Some(&occupied), out);
if highlight_macros_flag {
@ -272,11 +272,31 @@ fn highlight_c_like(
}
}
fn highlight_rust(line: &str, out: &mut Vec<HighlightSpan>) {
let bytes = line.as_bytes();
let comment_idx = find_line_comment(bytes, b'/', b'/');
let (body, comment) = split_comment(bytes, comment_idx);
highlight_rust_strings(body, out);
highlight_rust_char_literals(body, out);
let occupied = build_occupied(body.len(), out, &[HighlightClass::String]);
highlight_numbers_with_mask(body, Some(&occupied), true, out);
highlight_words_with_mask(body, RUST_KEYWORDS, HighlightClass::Keyword, Some(&occupied), out);
highlight_words_with_mask(body, RUST_TYPES, HighlightClass::Type, Some(&occupied), out);
highlight_rust_lifetimes(body, Some(&occupied), out);
highlight_macros(body, Some(&occupied), out);
highlight_preprocessor(body, out);
if let Some(range) = comment {
push_span(out, range, HighlightClass::Comment);
}
}
fn highlight_json(line: &str, out: &mut Vec<HighlightSpan>) {
let bytes = line.as_bytes();
highlight_strings(bytes, false, true, out);
let occupied = build_occupied(bytes.len(), out, &[HighlightClass::String]);
highlight_numbers_with_mask(bytes, Some(&occupied), out);
highlight_numbers_with_mask(bytes, Some(&occupied), false, out);
highlight_words_with_mask(
bytes,
&["true", "false", "null"],
@ -293,7 +313,7 @@ fn highlight_toml(line: &str, out: &mut Vec<HighlightSpan>) {
highlight_strings(body, true, true, out);
let occupied = build_occupied(body.len(), out, &[HighlightClass::String]);
highlight_numbers_with_mask(body, Some(&occupied), out);
highlight_numbers_with_mask(body, Some(&occupied), false, out);
if let Some(range) = find_key_span(body) {
push_span(out, range, HighlightClass::Keyword);
@ -331,7 +351,7 @@ fn highlight_python(line: &str, out: &mut Vec<HighlightSpan>) {
highlight_strings(body, true, true, out);
let occupied = build_occupied(body.len(), out, &[HighlightClass::String]);
highlight_numbers_with_mask(body, Some(&occupied), out);
highlight_numbers_with_mask(body, Some(&occupied), true, out);
highlight_words_with_mask(body, PYTHON_KEYWORDS, HighlightClass::Keyword, Some(&occupied), out);
if let Some(range) = comment {
@ -370,6 +390,213 @@ fn highlight_markdown(line: &str, out: &mut Vec<HighlightSpan>) {
}
}
fn highlight_rust_strings(bytes: &[u8], out: &mut Vec<HighlightSpan>) {
let mut idx = 0;
while idx < bytes.len() {
match bytes[idx] {
b'"' => {
let end = scan_rust_standard_string(bytes, idx, false).unwrap_or(bytes.len());
push_span(out, idx..end, HighlightClass::String);
idx = end;
}
b'b' => {
let Some(next) = bytes.get(idx + 1).copied() else {
idx += 1;
continue;
};
match next {
b'"' => {
let end =
scan_rust_standard_string(bytes, idx + 1, false).unwrap_or(bytes.len());
push_span(out, idx..end, HighlightClass::String);
idx = end;
}
b'\'' => {
if let Some(end) = scan_rust_standard_string(bytes, idx + 1, true) {
push_span(out, idx..end, HighlightClass::String);
idx = end;
} else {
idx += 1;
}
}
b'r' => {
if let Some(end) = scan_rust_raw_string(bytes, idx + 2) {
push_span(out, idx..end, HighlightClass::String);
idx = end;
} else {
idx += 1;
}
}
_ => idx += 1,
}
}
b'r' => {
if let Some(end) = scan_rust_raw_string(bytes, idx + 1) {
push_span(out, idx..end, HighlightClass::String);
idx = end;
} else {
idx += 1;
}
}
_ => idx += 1,
}
}
}
fn scan_rust_standard_string(
bytes: &[u8],
quote_idx: usize,
require_terminator: bool,
) -> Option<usize> {
let &delim = bytes.get(quote_idx)?;
let mut idx = quote_idx + 1;
while idx < bytes.len() {
let b = bytes[idx];
idx += 1;
if b == b'\\' && idx < bytes.len() {
idx += 1;
continue;
}
if b == delim {
return Some(idx);
}
}
if require_terminator { None } else { Some(bytes.len()) }
}
fn scan_rust_raw_string(bytes: &[u8], mut idx: usize) -> Option<usize> {
let mut hashes = 0;
while idx < bytes.len() && bytes[idx] == b'#' {
idx += 1;
hashes += 1;
}
if idx >= bytes.len() || bytes[idx] != b'"' {
return None;
}
idx += 1; // Skip the opening quote.
let mut cursor = idx;
while cursor < bytes.len() {
if bytes[cursor] == b'"' {
let mut end = cursor + 1;
let mut matched = 0;
while matched < hashes && end < bytes.len() && bytes[end] == b'#' {
end += 1;
matched += 1;
}
if matched == hashes {
return Some(end);
}
}
cursor += 1;
}
Some(bytes.len())
}
fn highlight_rust_char_literals(bytes: &[u8], out: &mut Vec<HighlightSpan>) {
let mut idx = 0;
while idx < bytes.len() {
if bytes[idx] == b'b' && idx + 1 < bytes.len() && bytes[idx + 1] == b'\'' {
if let Some(end) = scan_rust_char_literal(bytes, idx + 1) {
push_span(out, idx..end, HighlightClass::String);
idx = end;
continue;
}
idx += 1;
continue;
}
if bytes[idx] == b'\'' {
if let Some(end) = scan_rust_char_literal(bytes, idx) {
push_span(out, idx..end, HighlightClass::String);
idx = end;
} else {
idx += 1;
}
continue;
}
idx += 1;
}
}
fn scan_rust_char_literal(bytes: &[u8], quote_idx: usize) -> Option<usize> {
let mut idx = quote_idx + 1;
if idx >= bytes.len() {
return None;
}
if bytes[idx] == b'\\' {
idx += 1;
if idx >= bytes.len() {
return None;
}
match bytes[idx] {
b'x' => {
idx += 1;
let mut consumed = 0;
while consumed < 2 && idx < bytes.len() && bytes[idx].is_ascii_hexdigit() {
idx += 1;
consumed += 1;
}
if consumed == 0 {
return None;
}
}
b'u' => {
idx += 1;
if idx >= bytes.len() || bytes[idx] != b'{' {
return None;
}
idx += 1;
let mut consumed = 0;
while idx < bytes.len() && bytes[idx].is_ascii_hexdigit() {
idx += 1;
consumed += 1;
}
if consumed == 0 || idx >= bytes.len() || bytes[idx] != b'}' {
return None;
}
idx += 1;
}
_ => {
idx += 1;
}
}
} else {
let slice = std::str::from_utf8(&bytes[idx..]).ok()?;
let mut chars = slice.chars();
let ch = chars.next()?;
idx += ch.len_utf8();
}
if idx < bytes.len() && bytes[idx] == b'\'' { Some(idx + 1) } else { None }
}
fn highlight_rust_lifetimes(bytes: &[u8], occupied: Option<&[bool]>, out: &mut Vec<HighlightSpan>) {
let mut idx = 0;
while idx < bytes.len() {
if occupied.map_or(false, |mask| mask.get(idx).copied().unwrap_or(false)) {
idx += 1;
continue;
}
if bytes[idx] != b'\'' {
idx += 1;
continue;
}
let next = idx + 1;
if next >= bytes.len() || !is_ident_start(bytes[next]) {
idx += 1;
continue;
}
let start = idx;
idx += 2;
while idx < bytes.len() && is_ident_continue(bytes[idx]) {
idx += 1;
}
push_span(out, start..idx, HighlightClass::Type);
}
}
fn highlight_strings(
bytes: &[u8],
allow_single: bool,
@ -408,6 +635,7 @@ fn highlight_strings(
fn highlight_numbers_with_mask(
bytes: &[u8],
occupied: Option<&[bool]>,
allow_alpha_suffix: bool,
out: &mut Vec<HighlightSpan>,
) {
let mut idx = 0;
@ -422,11 +650,52 @@ fn highlight_numbers_with_mask(
}
let start = idx;
idx += 1;
while idx < bytes.len()
&& (bytes[idx].is_ascii_hexdigit() || matches!(bytes[idx], b'x' | b'X' | b'.' | b'_'))
if idx < bytes.len()
&& bytes[start] == b'0'
&& matches!(bytes[idx], b'b' | b'B' | b'o' | b'O' | b'x' | b'X')
{
idx += 1;
}
let mut allow_fraction = true;
while idx < bytes.len() {
let b = bytes[idx];
if b.is_ascii_hexdigit() || b == b'_' {
idx += 1;
continue;
}
if allow_fraction && b == b'.' {
if idx + 1 < bytes.len() && bytes[idx + 1].is_ascii_digit() {
idx += 1;
continue;
}
break;
}
if matches!(b, b'e' | b'E' | b'p' | b'P') {
let mut exp_idx = idx + 1;
if exp_idx < bytes.len() && matches!(bytes[exp_idx], b'+' | b'-') {
exp_idx += 1;
}
let exp_start = exp_idx;
while exp_idx < bytes.len() && bytes[exp_idx].is_ascii_digit() {
exp_idx += 1;
}
if exp_idx == exp_start {
break;
}
idx = exp_idx;
allow_fraction = false;
continue;
}
break;
}
if allow_alpha_suffix {
while idx < bytes.len() && (bytes[idx].is_ascii_alphanumeric() || bytes[idx] == b'#') {
idx += 1;
}
}
push_span(out, start..idx, HighlightClass::Number);
}
}
@ -592,3 +861,50 @@ fn build_occupied(len: usize, spans: &[HighlightSpan], classes: &[HighlightClass
}
occupied
}
#[cfg(test)]
mod tests {
use super::*;
fn collect_spans(kind: SyntaxKind, line: &str) -> Vec<HighlightSpan> {
let mut spans = Vec::new();
highlight_line(kind, line, &mut spans);
spans
}
fn has_span(spans: &[HighlightSpan], line: &str, class: HighlightClass, needle: &str) -> bool {
spans.iter().any(|span| span.class == class && &line[span.range.clone()] == needle)
}
#[test]
fn rust_lifetimes_are_not_strings() {
let line = "fn foo<'a>(x: &'a str, y: &'static str) {}";
let spans = collect_spans(SyntaxKind::Rust, line);
assert!(has_span(&spans, line, HighlightClass::Type, "'a"));
assert!(has_span(&spans, line, HighlightClass::Type, "'static"));
let lifetime_idx = line.find("'static").unwrap();
let in_string = spans.iter().any(|span| {
span.class == HighlightClass::String
&& span.range.start <= lifetime_idx
&& lifetime_idx < span.range.end
});
assert!(!in_string, "lifetime token should not be highlighted as a string");
}
#[test]
fn rust_raw_and_byte_strings_are_supported() {
let line = "let msg = r#\"hello\"#; let data = br##\"hi\"##;";
let spans = collect_spans(SyntaxKind::Rust, line);
assert!(has_span(&spans, line, HighlightClass::String, "r#\"hello\"#"));
assert!(has_span(&spans, line, HighlightClass::String, "br##\"hi\"##"));
}
#[test]
fn numbers_cover_prefixes_and_suffixes() {
let line = "let size = 10usize + 0b1010;";
let spans = collect_spans(SyntaxKind::Rust, line);
assert!(has_span(&spans, line, HighlightClass::Number, "10usize"));
assert!(has_span(&spans, line, HighlightClass::Number, "0b1010"));
}
}

View file

@ -9,7 +9,7 @@
use std::mem;
use crate::helpers::{CoordType, Point, Size};
use crate::vt;
use crate::{base64, vt};
/// Represents a key/modifier combination.
///
@ -270,6 +270,7 @@ pub struct Parser {
x10_mouse_want: bool,
x10_mouse_buf: [u8; 3],
x10_mouse_len: usize,
osc_buf: String,
}
impl Parser {
@ -283,6 +284,7 @@ impl Parser {
x10_mouse_want: false,
x10_mouse_buf: [0; 3],
x10_mouse_len: 0,
osc_buf: String::new(),
}
}
@ -330,6 +332,11 @@ impl<'input> Iterator for Stream<'_, '_, 'input> {
vt::Token::Text(text) => {
return Some(Input::Text(text));
}
vt::Token::Osc { data, partial } => {
if let Some(input) = self.handle_osc_sequence(data, partial) {
return Some(input);
}
}
vt::Token::Ctrl(ch) => match ch {
'\0' | '\t' | '\r' => return Some(Input::Keyboard(InputKey::new(ch as u32))),
'\n' => return Some(Input::Keyboard(kbmod::CTRL | vk::RETURN)),
@ -526,6 +533,42 @@ impl<'input> Stream<'_, '_, 'input> {
}
}
fn handle_osc_sequence(&mut self, data: &str, partial: bool) -> Option<Input<'input>> {
if partial {
self.parser.osc_buf.push_str(data);
return None;
}
if self.parser.osc_buf.is_empty() {
return Self::parse_clipboard_osc(data);
}
self.parser.osc_buf.push_str(data);
let result = Self::parse_clipboard_osc(&self.parser.osc_buf);
self.parser.osc_buf.clear();
result
}
fn parse_clipboard_osc(data: &str) -> Option<Input<'input>> {
let mut parts = data.splitn(3, ';');
match parts.next()? {
"52" => {}
_ => return None,
}
// selection parameter, e.g. "c". We currently accept any value.
let _ = parts.next();
let payload = parts.next().unwrap_or("");
if payload == "?" {
return None;
}
let bytes = if payload.is_empty() { Vec::new() } else { base64::decode(payload)? };
Some(Input::Paste(bytes))
}
/// Implements the X10 mouse protocol via `CSI M CbCxCy`.
///
/// You want to send numeric mouse coordinates.
@ -584,3 +627,20 @@ impl<'input> Stream<'_, '_, 'input> {
modifiers
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn osc52_response_emits_paste() {
let mut vt_parser = vt::Parser::new();
let mut parser = Parser::new();
let vt_stream = vt_parser.parse("\x1b]52;c;U3lzdGVtIFBhc3Rl\x07");
let mut iter = parser.parse(vt_stream);
match iter.next() {
Some(Input::Paste(bytes)) => assert_eq!(bytes, b"System Paste"),
_ => panic!("unexpected input"),
}
}
}

View file

@ -162,6 +162,7 @@ use crate::oklab::StraightRgba;
use crate::{apperr, arena_format, input, simd, unicode};
const ROOT_ID: u64 = 0x14057B7EF767814F; // Knuth's MMIX constant
const CLIPBOARD_REQUEST_TIMEOUT: time::Duration = time::Duration::from_millis(150);
const SHIFT_TAB: InputKey = vk::TAB.with_modifiers(kbmod::SHIFT);
const KBMOD_FOR_WORD_NAV: InputKeyMod =
if cfg!(target_os = "macos") { kbmod::ALT } else { kbmod::CTRL };
@ -365,12 +366,21 @@ pub struct Tui {
/// The clipboard contents.
clipboard: Clipboard,
clipboard_query_pending: bool,
clipboard_request_started: Option<std::time::Instant>,
clipboard_host_supported: Option<bool>,
synthetic_ctrl_v_pending: bool,
settling_have: i32,
settling_want: i32,
read_timeout: time::Duration,
}
enum CtrlVPasteDecision {
PasteNow,
Wait,
}
impl Tui {
/// Creates a new [`Tui`] instance for storing state across frames.
pub fn new() -> apperr::Result<Self> {
@ -415,6 +425,10 @@ impl Tui {
cached_text_buffers: Vec::with_capacity(16),
clipboard: Default::default(),
clipboard_query_pending: false,
clipboard_request_started: None,
clipboard_host_supported: None,
synthetic_ctrl_v_pending: false,
settling_have: 0,
settling_want: 0,
@ -502,6 +516,53 @@ impl Tui {
&mut self.clipboard
}
fn ctrl_v_decision(&mut self) -> CtrlVPasteDecision {
if self.synthetic_ctrl_v_pending {
self.synthetic_ctrl_v_pending = false;
return CtrlVPasteDecision::PasteNow;
}
if self.clipboard_request_started.is_some() {
return CtrlVPasteDecision::Wait;
}
if self.clipboard_host_supported == Some(false) {
return CtrlVPasteDecision::PasteNow;
}
self.clipboard_request_started = Some(std::time::Instant::now());
self.clipboard_query_pending = true;
self.needs_more_settling();
CtrlVPasteDecision::Wait
}
fn mark_clipboard_paste_received(&mut self) {
if self.clipboard_request_started.is_some() {
self.clipboard_request_started = None;
self.clipboard_host_supported = Some(true);
}
self.synthetic_ctrl_v_pending = true;
self.needs_more_settling();
}
pub fn take_clipboard_request(&mut self) -> bool {
mem::take(&mut self.clipboard_query_pending)
}
fn poll_clipboard_timeout(&mut self) {
if let Some(started) = self.clipboard_request_started
&& started.elapsed() > CLIPBOARD_REQUEST_TIMEOUT
{
self.clipboard_request_started = None;
self.clipboard_query_pending = false;
if self.clipboard_host_supported.is_none() {
self.clipboard_host_supported = Some(false);
}
self.synthetic_ctrl_v_pending = true;
self.needs_more_settling();
}
}
/// Starts a new frame and returns a [`Context`] for it.
pub fn create_context<'a, 'input>(
&'a mut self,
@ -533,12 +594,14 @@ impl Tui {
// `self.needs_settling() == true`. However, there's a possibility for it being true from
// a previous frame, and we do have fresh new input. In that case want `input_consumed`
// to be false of course which is ensured by checking for `input.is_none()`.
let input_consumed = self.needs_settling() && input.is_none();
let mut input_consumed = self.needs_settling() && input.is_none();
if self.scroll_to_focused() {
self.needs_more_settling();
}
self.poll_clipboard_timeout();
match input {
None => {}
Some(Input::Resize(resize)) => {
@ -562,6 +625,7 @@ impl Tui {
let clipboard = self.clipboard_mut();
clipboard.write(paste);
clipboard.mark_as_synchronized();
self.mark_clipboard_paste_received();
input_keyboard = Some(kbmod::CTRL | vk::V);
}
Some(Input::Keyboard(keyboard)) => {
@ -682,6 +746,11 @@ impl Tui {
}
}
if input_keyboard.is_none() && self.synthetic_ctrl_v_pending {
input_keyboard = Some(kbmod::CTRL | vk::V);
input_consumed = false;
}
if !input_consumed {
// Every time there's input, we naturally need to re-render at least once.
self.settling_have = 0;
@ -1421,6 +1490,11 @@ impl<'a> Context<'a, '_> {
&mut self.tui.clipboard
}
/// Returns whether the terminal supports OSC 52 clipboard reads.
pub fn clipboard_host_support(&self) -> Option<bool> {
self.tui.clipboard_host_supported
}
/// Tell the UI framework that your state changed and you need another layout pass.
pub fn needs_rerender(&mut self) {
// If this hits, the call stack is responsible is trying to deadlock you.
@ -2712,7 +2786,10 @@ impl<'a> Context<'a, '_> {
_ => return false,
},
vk::V => match modifiers {
kbmod::CTRL => tb.paste(self.clipboard_ref()),
kbmod::CTRL => match self.tui.ctrl_v_decision() {
CtrlVPasteDecision::PasteNow => tb.paste(self.clipboard_ref()),
CtrlVPasteDecision::Wait => self.needs_rerender(),
},
_ => return false,
},
vk::Y => match modifiers {