mirror of
https://github.com/microsoft/edit.git
synced 2025-12-23 07:07:25 +00:00
Improve highlighting and clipboard paste flow
This commit is contained in:
parent
8ed71f40dc
commit
2f01ef2db2
7 changed files with 588 additions and 19 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
330
src/highlight.rs
330
src/highlight.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/input.rs
62
src/input.rs
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
src/tui.rs
81
src/tui.rs
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue