feat: add syntax highlighting to text buffers
This commit is contained in:
+15
-1
@@ -693,6 +693,7 @@ struct Buffer {
|
||||
id: usize,
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
text_revision: u64,
|
||||
targets: Vec<Target>,
|
||||
primary: usize,
|
||||
mode: Mode,
|
||||
@@ -731,6 +732,7 @@ impl Buffer {
|
||||
id,
|
||||
saved_lines: lines.clone(),
|
||||
lines,
|
||||
text_revision: 0,
|
||||
targets: vec![Target::caret(Cursor::default())],
|
||||
primary: 0,
|
||||
mode: Mode::Normal,
|
||||
@@ -938,6 +940,10 @@ impl Editor {
|
||||
pub fn lines(&self) -> &[String] {
|
||||
&self.active.lines
|
||||
}
|
||||
/// Changes only when this buffer's text changes, independent of cursor/view state.
|
||||
pub fn text_revision(&self) -> u64 {
|
||||
self.active.text_revision
|
||||
}
|
||||
pub fn cursor(&self) -> Cursor {
|
||||
self.active.targets[self.active.primary].active
|
||||
}
|
||||
@@ -2267,7 +2273,11 @@ impl Editor {
|
||||
match effect {
|
||||
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
|
||||
Effect::Replace(text) => {
|
||||
self.active.lines = split_lines(&text);
|
||||
let lines = split_lines(&text);
|
||||
if lines != self.active.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
}
|
||||
self.active.lines = lines;
|
||||
self.active.search_match = None;
|
||||
self.normalize_targets();
|
||||
}
|
||||
@@ -3165,6 +3175,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn restore(&mut self, snapshot: Snapshot) {
|
||||
if self.active.lines != snapshot.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
}
|
||||
self.active.lines = snapshot.lines;
|
||||
self.active.targets = snapshot.targets;
|
||||
self.active.primary = snapshot.primary;
|
||||
@@ -3377,6 +3390,7 @@ impl Editor {
|
||||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||||
}
|
||||
if text != old {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.search_match = None;
|
||||
}
|
||||
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
||||
|
||||
@@ -3,4 +3,5 @@ pub mod editor;
|
||||
pub mod files;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
pub mod syntax;
|
||||
pub mod tui;
|
||||
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use syntect::highlighting::ScopeSelectors;
|
||||
use syntect::parsing::{
|
||||
ParseState, ScopeError, ScopeStack, ScopeStackOp, SyntaxReference, SyntaxSet,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SyntaxKind {
|
||||
Comment,
|
||||
String,
|
||||
Keyword,
|
||||
Number,
|
||||
Type,
|
||||
Function,
|
||||
Markup,
|
||||
}
|
||||
|
||||
/// A semantic foreground range in the original line's bytes, excluding its newline.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SyntaxSpan {
|
||||
pub start_byte: usize,
|
||||
pub end_byte: usize,
|
||||
pub kind: SyntaxKind,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CachedLine {
|
||||
source: String,
|
||||
spans: Vec<SyntaxSpan>,
|
||||
after: Option<(ParseState, ScopeStack)>,
|
||||
}
|
||||
|
||||
/// Frontend-owned, per-buffer parser state. Revision and path identify unchanged input.
|
||||
#[derive(Default)]
|
||||
pub struct SyntaxCache {
|
||||
revision: Option<u64>,
|
||||
path: Option<PathBuf>,
|
||||
syntax: Option<&'static SyntaxReference>,
|
||||
lines: Vec<CachedLine>,
|
||||
parse_line: String,
|
||||
error: Option<String>,
|
||||
#[cfg(test)]
|
||||
parsed_lines: usize,
|
||||
}
|
||||
|
||||
impl SyntaxCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `revision` must change whenever line content changes; path changes are detected separately.
|
||||
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
|
||||
let path_changed = self.path.as_deref() != path;
|
||||
if self.revision == Some(revision) && !path_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = self
|
||||
.lines
|
||||
.iter()
|
||||
.zip(lines)
|
||||
.take_while(|(cached, source)| cached.source == **source)
|
||||
.count();
|
||||
let source_changed = prefix != self.lines.len() || prefix != lines.len();
|
||||
let syntaxes = syntax_set();
|
||||
let syntax = if self.revision.is_none() || path_changed || (source_changed && prefix == 0) {
|
||||
detect_syntax(syntaxes, path, lines.first().map(String::as_str))
|
||||
} else {
|
||||
self.syntax
|
||||
};
|
||||
let syntax_changed = self.language() != syntax.map(|syntax| syntax.name.as_str());
|
||||
|
||||
self.revision = Some(revision);
|
||||
if path_changed {
|
||||
self.path = path.map(Path::to_path_buf);
|
||||
}
|
||||
self.syntax = syntax;
|
||||
if !source_changed && !syntax_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.lines.truncate(lines.len());
|
||||
for (row, source) in lines.iter().enumerate().skip(prefix) {
|
||||
if let Some(cached) = self.lines.get_mut(row) {
|
||||
if cached.source != *source {
|
||||
cached.source.clone_from(source);
|
||||
}
|
||||
} else {
|
||||
self.lines.push(CachedLine {
|
||||
source: source.clone(),
|
||||
..CachedLine::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let Some(syntax) = syntax else {
|
||||
self.clear_highlighting();
|
||||
self.error = None;
|
||||
return;
|
||||
};
|
||||
let start = if syntax_changed || self.error.is_some() {
|
||||
0
|
||||
} else {
|
||||
prefix
|
||||
};
|
||||
self.error = None;
|
||||
if start < self.lines.len() {
|
||||
self.parse_from(start, syntax, syntaxes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted, non-overlapping, end-exclusive ranges. Unclassified text uses the normal foreground.
|
||||
pub fn line(&self, row: usize) -> &[SyntaxSpan] {
|
||||
self.lines.get(row).map_or(&[], |line| &line.spans)
|
||||
}
|
||||
|
||||
/// Bundled grammar name, or `None` for unknown languages and Plain Text.
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.syntax.map(|syntax| syntax.name.as_str())
|
||||
}
|
||||
|
||||
/// A genuine parser/scope error disables highlighting for the whole buffer until input changes.
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
self.error.as_deref()
|
||||
}
|
||||
|
||||
fn clear_highlighting(&mut self) {
|
||||
for line in &mut self.lines {
|
||||
line.spans.clear();
|
||||
line.after = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_from(&mut self, start: usize, syntax: &SyntaxReference, syntaxes: &SyntaxSet) {
|
||||
let (mut parser, mut scopes) = if start == 0 {
|
||||
(ParseState::new(syntax), ScopeStack::new())
|
||||
} else {
|
||||
self.lines[start - 1]
|
||||
.after
|
||||
.as_ref()
|
||||
.expect("unchanged prefix has a parser checkpoint")
|
||||
.clone()
|
||||
};
|
||||
|
||||
// ponytail: parse the dirty suffix to EOF; stop at converged checkpoints if this becomes costly.
|
||||
for row in start..self.lines.len() {
|
||||
let cached = &mut self.lines[row];
|
||||
self.parse_line.clear();
|
||||
self.parse_line.push_str(&cached.source);
|
||||
self.parse_line.push('\n');
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.parsed_lines += 1;
|
||||
}
|
||||
let result = parser
|
||||
.parse_line(&self.parse_line, syntaxes)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|changes| {
|
||||
collect_spans(
|
||||
cached.source.len(),
|
||||
&changes,
|
||||
&mut scopes,
|
||||
&mut cached.spans,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.error = Some(format!("{} syntax, line {}: {error}", syntax.name, row + 1));
|
||||
self.clear_highlighting();
|
||||
return;
|
||||
}
|
||||
cached.after = Some((parser.clone(), scopes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn syntax_set() -> &'static SyntaxSet {
|
||||
static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
|
||||
&SYNTAXES
|
||||
}
|
||||
|
||||
fn detect_syntax<'a>(
|
||||
syntaxes: &'a SyntaxSet,
|
||||
path: Option<&Path>,
|
||||
first_line: Option<&str>,
|
||||
) -> Option<&'a SyntaxReference> {
|
||||
let by_path = path.and_then(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| syntaxes.find_syntax_by_extension(name))
|
||||
.or_else(|| {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.and_then(|extension| syntaxes.find_syntax_by_extension(extension))
|
||||
})
|
||||
});
|
||||
by_path
|
||||
.or_else(|| first_line.and_then(|line| syntaxes.find_syntax_by_first_line(line)))
|
||||
.filter(|syntax| syntax.name != "Plain Text")
|
||||
}
|
||||
|
||||
fn classify(scopes: &ScopeStack) -> Option<SyntaxKind> {
|
||||
static SELECTORS: LazyLock<[(ScopeSelectors, SyntaxKind); 7]> = LazyLock::new(|| {
|
||||
[
|
||||
("comment", SyntaxKind::Comment),
|
||||
("string", SyntaxKind::String),
|
||||
("keyword, storage, constant.language", SyntaxKind::Keyword),
|
||||
("constant.numeric", SyntaxKind::Number),
|
||||
(
|
||||
"entity.name.type, entity.name.class, entity.name.struct, entity.name.enum, \
|
||||
entity.name.trait, support.type, support.class",
|
||||
SyntaxKind::Type,
|
||||
),
|
||||
(
|
||||
"entity.name.function, support.function",
|
||||
SyntaxKind::Function,
|
||||
),
|
||||
("markup, entity.name.tag", SyntaxKind::Markup),
|
||||
]
|
||||
.map(|(selector, kind)| {
|
||||
(
|
||||
selector.parse().expect("fixed semantic scope selector"),
|
||||
kind,
|
||||
)
|
||||
})
|
||||
});
|
||||
SELECTORS
|
||||
.iter()
|
||||
.filter_map(|(selector, kind)| {
|
||||
selector
|
||||
.does_match(scopes.as_slice())
|
||||
.map(|power| (power, *kind))
|
||||
})
|
||||
.max_by_key(|(power, _)| *power)
|
||||
.map(|(_, kind)| kind)
|
||||
}
|
||||
|
||||
fn collect_spans(
|
||||
line_len: usize,
|
||||
changes: &[(usize, ScopeStackOp)],
|
||||
scopes: &mut ScopeStack,
|
||||
spans: &mut Vec<SyntaxSpan>,
|
||||
) -> Result<(), ScopeError> {
|
||||
spans.clear();
|
||||
let mut start = 0;
|
||||
let mut changes = changes.iter().peekable();
|
||||
while let Some((offset, operation)) = changes.next() {
|
||||
let end = (*offset).min(line_len);
|
||||
if start < end {
|
||||
append_span(spans, start, end, classify(scopes));
|
||||
}
|
||||
scopes.apply(operation)?;
|
||||
while changes.peek().is_some_and(|(next, _)| next == offset) {
|
||||
scopes.apply(&changes.next().unwrap().1)?;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if start < line_len {
|
||||
append_span(spans, start, line_len, classify(scopes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_span(spans: &mut Vec<SyntaxSpan>, start: usize, end: usize, kind: Option<SyntaxKind>) {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = kind else {
|
||||
return;
|
||||
};
|
||||
if let Some(previous) = spans.last_mut()
|
||||
&& previous.end_byte == start
|
||||
&& previous.kind == kind
|
||||
{
|
||||
previous.end_byte = end;
|
||||
} else {
|
||||
spans.push(SyntaxSpan {
|
||||
start_byte: start,
|
||||
end_byte: end,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use syntect::parsing::{ClearAmount, Scope};
|
||||
|
||||
fn lines(source: &str) -> Vec<String> {
|
||||
source.split('\n').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn kind_at(cache: &SyntaxCache, row: usize, byte: usize) -> Option<SyntaxKind> {
|
||||
cache
|
||||
.line(row)
|
||||
.iter()
|
||||
.find_map(|span| (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind))
|
||||
}
|
||||
|
||||
fn assert_token(
|
||||
cache: &SyntaxCache,
|
||||
source: &[String],
|
||||
row: usize,
|
||||
token: &str,
|
||||
kind: SyntaxKind,
|
||||
) {
|
||||
let start = source[row].find(token).unwrap();
|
||||
for (byte, _) in source[row][start..start + token.len()].char_indices() {
|
||||
assert_eq!(
|
||||
kind_at(cache, row, start + byte),
|
||||
Some(kind),
|
||||
"{token:?} on row {row}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fresh(cache: &SyntaxCache, source: &[String], path: Option<&Path>) {
|
||||
let mut fresh = SyntaxCache::new();
|
||||
fresh.update(source, path, 0);
|
||||
assert_eq!(cache.language(), fresh.language());
|
||||
assert_eq!(cache.error(), fresh.error());
|
||||
for row in 0..source.len() {
|
||||
assert_eq!(cache.line(row), fresh.line(row), "row {row}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_languages_supply_semantic_scopes() {
|
||||
let cases = [
|
||||
(
|
||||
"sample.rs",
|
||||
"Rust",
|
||||
"fn main() { let value = 42; let text = \"hello\"; /* note */ }",
|
||||
"fn",
|
||||
"main",
|
||||
),
|
||||
(
|
||||
"sample.rb",
|
||||
"Ruby",
|
||||
"def hello\n value = 42\n text = \"hello\" # note\nend",
|
||||
"def",
|
||||
"hello",
|
||||
),
|
||||
(
|
||||
"sample.py",
|
||||
"Python",
|
||||
"def hello():\n value = 42\n text = \"hello\" # note",
|
||||
"def",
|
||||
"hello",
|
||||
),
|
||||
];
|
||||
for (path, language, text, keyword, function) in cases {
|
||||
let source = lines(text);
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, Some(Path::new(path)), 0);
|
||||
assert_eq!(cache.language(), Some(language));
|
||||
assert_eq!(cache.error(), None);
|
||||
assert_token(&cache, &source, 0, keyword, SyntaxKind::Keyword);
|
||||
assert_token(&cache, &source, 0, function, SyntaxKind::Function);
|
||||
let number_row = if language == "Rust" { 0 } else { 1 };
|
||||
let string_row = if language == "Rust" { 0 } else { 2 };
|
||||
assert_token(&cache, &source, number_row, "42", SyntaxKind::Number);
|
||||
// Select the quoted occurrence, not a function with the same name.
|
||||
let quoted = source[string_row].find("\"hello\"").unwrap();
|
||||
assert_eq!(
|
||||
kind_at(&cache, string_row, quoted + 1),
|
||||
Some(SyntaxKind::String)
|
||||
);
|
||||
assert_token(&cache, &source, string_row, "note", SyntaxKind::Comment);
|
||||
}
|
||||
|
||||
let source = lines("{\"message\":\"hello\",\"n\":42,\"enabled\":true}");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, Some(Path::new("sample.json")), 0);
|
||||
assert_eq!(cache.language(), Some("JSON"));
|
||||
assert_token(&cache, &source, 0, "hello", SyntaxKind::String);
|
||||
assert_token(&cache, &source, 0, "42", SyntaxKind::Number);
|
||||
assert_token(&cache, &source, 0, "true", SyntaxKind::Keyword);
|
||||
assert_eq!(cache.error(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_bundled_special_filenames_extensions_and_actual_shebangs() {
|
||||
let source = lines("all:\n\techo hello");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, Some(Path::new("/not/read/from/disk/Makefile")), 0);
|
||||
assert_eq!(cache.language(), Some("Makefile"));
|
||||
|
||||
let source = lines("#!/usr/bin/env bash\necho \"hello\" # note");
|
||||
cache.update(&source, Some(Path::new("/not/read/from/disk/script")), 1);
|
||||
assert_eq!(cache.language(), Some("Bourne Again Shell (bash)"));
|
||||
assert_token(&cache, &source, 1, "hello", SyntaxKind::String);
|
||||
assert_token(&cache, &source, 1, "note", SyntaxKind::Comment);
|
||||
assert_eq!(cache.error(), None);
|
||||
|
||||
let source = lines("echo \"hello\" # note");
|
||||
cache.update(&source, Some(Path::new(".bashrc")), 1);
|
||||
assert_eq!(cache.language(), Some("Bourne Again Shell (bash)"));
|
||||
assert_token(&cache, &source, 0, "hello", SyntaxKind::String);
|
||||
|
||||
let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42");
|
||||
cache.update(&source, None, 2);
|
||||
assert_eq!(cache.language(), Some("Python"));
|
||||
assert_token(&cache, &source, 1, "return", SyntaxKind::Keyword);
|
||||
cache.update(&source, Some(Path::new("sample.RS")), 2);
|
||||
assert_eq!(cache.language(), Some("Rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_plain_text_have_no_spans_or_errors() {
|
||||
let source = lines("fn main() { \"hello\"; 42; }\n/* note");
|
||||
let mut cache = SyntaxCache::new();
|
||||
for path in [
|
||||
None,
|
||||
Some(Path::new("sample.unknown-extension")),
|
||||
Some(Path::new("sample.txt")),
|
||||
] {
|
||||
cache.update(&source, path, 0);
|
||||
assert_eq!(cache.language(), None);
|
||||
assert_eq!(cache.error(), None);
|
||||
assert!(cache.line(0).is_empty());
|
||||
assert!(cache.line(1).is_empty());
|
||||
assert!(cache.line(500).is_empty());
|
||||
}
|
||||
cache.update(&[], Some(Path::new("empty.rs")), 1);
|
||||
assert_eq!(cache.language(), Some("Rust"));
|
||||
assert!(cache.line(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_multiline_comments_and_edits_before_visible_rows() {
|
||||
let mut source = lines("/* outer\n/* inner */ still outer\nlast */ fn main() {}\n// tail");
|
||||
let path = Some(Path::new("sample.rs"));
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, path, 0);
|
||||
assert_token(&cache, &source, 1, "inner", SyntaxKind::Comment);
|
||||
assert_token(&cache, &source, 1, "still outer", SyntaxKind::Comment);
|
||||
assert_token(&cache, &source, 2, "last", SyntaxKind::Comment);
|
||||
assert_token(&cache, &source, 2, "fn", SyntaxKind::Keyword);
|
||||
|
||||
source[0] = "fn earlier() {}".into();
|
||||
cache.update(&source, path, 1);
|
||||
assert_eq!(
|
||||
kind_at(&cache, 1, source[1].find("still outer").unwrap()),
|
||||
None
|
||||
);
|
||||
assert_eq!(kind_at(&cache, 2, 0), None);
|
||||
assert_fresh(&cache, &source, path);
|
||||
|
||||
source[0] = "/* outer".into();
|
||||
cache.update(&source, path, 2);
|
||||
assert_token(&cache, &source, 2, "last", SyntaxKind::Comment);
|
||||
assert_fresh(&cache, &source, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_strings_preserve_unicode_byte_ranges_and_tolerate_unfinished_source() {
|
||||
let mut source = lines(
|
||||
"text = \"\"\"start\n\t界e\u{301}👩💻 middle\nfinish\"\"\"\nreturn 42 # 界e\u{301}👩💻",
|
||||
);
|
||||
let path = Some(Path::new("sample.py"));
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, path, 0);
|
||||
assert_token(
|
||||
&cache,
|
||||
&source,
|
||||
1,
|
||||
"\t界e\u{301}👩💻 middle",
|
||||
SyntaxKind::String,
|
||||
);
|
||||
assert_token(&cache, &source, 2, "finish", SyntaxKind::String);
|
||||
assert_token(&cache, &source, 3, "return", SyntaxKind::Keyword);
|
||||
assert_token(&cache, &source, 3, "界e\u{301}👩💻", SyntaxKind::Comment);
|
||||
for (row, line) in source.iter().enumerate() {
|
||||
let mut end = 0;
|
||||
for span in cache.line(row) {
|
||||
assert!(end <= span.start_byte && span.start_byte < span.end_byte);
|
||||
assert!(span.end_byte <= line.len());
|
||||
assert!(line.is_char_boundary(span.start_byte));
|
||||
assert!(line.is_char_boundary(span.end_byte));
|
||||
end = span.end_byte;
|
||||
}
|
||||
}
|
||||
source[2] = "unfinished".into();
|
||||
cache.update(&source, path, 1);
|
||||
assert_token(&cache, &source, 3, "return", SyntaxKind::String);
|
||||
assert_eq!(cache.error(), None);
|
||||
assert_fresh(&cache, &source, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_keep_scopes_and_unicode_before_tokens_keeps_byte_offsets() {
|
||||
let source = lines("/* first\n\n\t界e\u{301}👩💻 last */ fn main() { let value = 42; }");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, Some(Path::new("sample.rs")), 0);
|
||||
assert!(cache.line(1).is_empty());
|
||||
assert_token(&cache, &source, 2, "界e\u{301}👩💻 last", SyntaxKind::Comment);
|
||||
assert_token(&cache, &source, 2, "fn", SyntaxKind::Keyword);
|
||||
assert_token(&cache, &source, 2, "main", SyntaxKind::Function);
|
||||
assert_token(&cache, &source, 2, "42", SyntaxKind::Number);
|
||||
for span in cache.line(2) {
|
||||
assert!(source[2].is_char_boundary(span.start_byte));
|
||||
assert!(source[2].is_char_boundary(span.end_byte));
|
||||
assert!(span.end_byte <= source[2].len());
|
||||
}
|
||||
assert_eq!(cache.error(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revisions_reuse_prefix_checkpoints_and_unchanged_source_snapshots() {
|
||||
let mut source = lines("fn main() {\n /* note\n continued */\n let value = 42;\n}");
|
||||
let path = Some(Path::new("sample.rs"));
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, path, 10);
|
||||
assert_eq!(cache.parsed_lines, 5);
|
||||
let prefix_source = cache.lines[0].source.as_ptr();
|
||||
let prefix_spans = cache.lines[0].spans.as_ptr();
|
||||
cache.update(&source, path, 10);
|
||||
cache.update(&source, path, 11);
|
||||
assert_eq!(cache.parsed_lines, 5);
|
||||
|
||||
source[2] = " changed */".into();
|
||||
cache.update(&source, path, 12);
|
||||
assert_eq!(cache.parsed_lines, 8);
|
||||
assert_eq!(cache.lines[0].source.as_ptr(), prefix_source);
|
||||
assert_eq!(cache.lines[0].spans.as_ptr(), prefix_spans);
|
||||
assert_token(&cache, &source, 2, "changed", SyntaxKind::Comment);
|
||||
assert_fresh(&cache, &source, path);
|
||||
|
||||
source[2] = " continued */".into();
|
||||
cache.update(&source, path, 13);
|
||||
assert_eq!(cache.parsed_lines, 11);
|
||||
assert_fresh(&cache, &source, path);
|
||||
cache.update(&source, path, 14);
|
||||
assert_eq!(cache.parsed_lines, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_removal_and_appending_preserve_multiline_state() {
|
||||
let path = Some(Path::new("sample.py"));
|
||||
let mut source = lines("text = \"\"\"start\nmiddle\nend\"\"\"\nreturn 42");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, path, 0);
|
||||
assert_eq!(cache.parsed_lines, 4);
|
||||
|
||||
source.insert(1, "inserted 界".into());
|
||||
cache.update(&source, path, 1);
|
||||
assert_eq!(cache.parsed_lines, 8);
|
||||
assert_token(&cache, &source, 2, "middle", SyntaxKind::String);
|
||||
assert_fresh(&cache, &source, path);
|
||||
|
||||
source.remove(1);
|
||||
cache.update(&source, path, 2);
|
||||
assert_eq!(cache.parsed_lines, 11);
|
||||
assert_fresh(&cache, &source, path);
|
||||
|
||||
source.push("# appended".into());
|
||||
cache.update(&source, path, 3);
|
||||
assert_eq!(cache.parsed_lines, 12);
|
||||
assert_token(&cache, &source, 4, "appended", SyntaxKind::Comment);
|
||||
source.pop();
|
||||
cache.update(&source, path, 4);
|
||||
assert_eq!(cache.parsed_lines, 12);
|
||||
assert!(cache.line(4).is_empty());
|
||||
assert_fresh(&cache, &source, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_at_the_same_revision_and_shebang_edits_redetect_language() {
|
||||
let mut source = lines("fn main() { let value = 42; }");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, Some(Path::new("old.rs")), 7);
|
||||
assert_eq!(cache.parsed_lines, 1);
|
||||
cache.update(&source, Some(Path::new("new.RS")), 7);
|
||||
assert_eq!(cache.parsed_lines, 1);
|
||||
cache.update(&source, Some(Path::new("new.txt")), 7);
|
||||
assert_eq!(cache.language(), None);
|
||||
assert!(cache.line(0).is_empty());
|
||||
cache.update(&source, Some(Path::new("new.rs")), 7);
|
||||
assert_eq!(cache.parsed_lines, 2);
|
||||
assert_token(&cache, &source, 0, "fn", SyntaxKind::Keyword);
|
||||
|
||||
source = lines("#!/usr/bin/env bash\necho \"hello\"");
|
||||
cache.update(&source, None, 8);
|
||||
assert_eq!(cache.language(), Some("Bourne Again Shell (bash)"));
|
||||
source[0] = "#!/usr/bin/env python3".into();
|
||||
source[1] = "return \"hello\"".into();
|
||||
cache.update(&source, None, 9);
|
||||
assert_eq!(cache.language(), Some("Python"));
|
||||
assert_token(&cache, &source, 1, "return", SyntaxKind::Keyword);
|
||||
assert_fresh(&cache, &source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_function_and_markup_categories_come_from_scopes() {
|
||||
let mut cache = SyntaxCache::new();
|
||||
let source = lines("struct Widget;\nfn build() {}");
|
||||
cache.update(&source, Some(Path::new("sample.rs")), 0);
|
||||
assert_token(&cache, &source, 0, "Widget", SyntaxKind::Type);
|
||||
assert_token(&cache, &source, 1, "build", SyntaxKind::Function);
|
||||
let source = lines("# Heading");
|
||||
cache.update(&source, Some(Path::new("sample.md")), 1);
|
||||
assert_token(&cache, &source, 0, "Heading", SyntaxKind::Markup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepest_scope_match_wins_and_clear_restore_survives_checkpoints() {
|
||||
let mut scopes: ScopeStack = "source.python string.quoted.double.python".parse().unwrap();
|
||||
let mut spans = Vec::new();
|
||||
let changes = [
|
||||
(2, ScopeStackOp::Clear(ClearAmount::All)),
|
||||
(2, ScopeStackOp::Push(Scope::new("source.rust").unwrap())),
|
||||
(
|
||||
2,
|
||||
ScopeStackOp::Push(Scope::new("comment.block.rust").unwrap()),
|
||||
),
|
||||
];
|
||||
collect_spans(4, &changes, &mut scopes, &mut spans).unwrap();
|
||||
assert_eq!(
|
||||
spans,
|
||||
[
|
||||
SyntaxSpan {
|
||||
start_byte: 0,
|
||||
end_byte: 2,
|
||||
kind: SyntaxKind::String
|
||||
},
|
||||
SyntaxSpan {
|
||||
start_byte: 2,
|
||||
end_byte: 4,
|
||||
kind: SyntaxKind::Comment
|
||||
},
|
||||
]
|
||||
);
|
||||
let mut restored = scopes.clone();
|
||||
collect_spans(
|
||||
4,
|
||||
&[(1, ScopeStackOp::Pop(2)), (1, ScopeStackOp::Restore)],
|
||||
&mut restored,
|
||||
&mut spans,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
spans,
|
||||
[
|
||||
SyntaxSpan {
|
||||
start_byte: 0,
|
||||
end_byte: 1,
|
||||
kind: SyntaxKind::Comment
|
||||
},
|
||||
SyntaxSpan {
|
||||
start_byte: 1,
|
||||
end_byte: 4,
|
||||
kind: SyntaxKind::String
|
||||
},
|
||||
]
|
||||
);
|
||||
let before = restored.clone();
|
||||
collect_spans(
|
||||
3,
|
||||
&[
|
||||
(1, ScopeStackOp::Clear(ClearAmount::TopN(1))),
|
||||
(
|
||||
1,
|
||||
ScopeStackOp::Push(Scope::new("constant.numeric.python").unwrap()),
|
||||
),
|
||||
(2, ScopeStackOp::Pop(1)),
|
||||
(2, ScopeStackOp::Restore),
|
||||
],
|
||||
&mut restored,
|
||||
&mut spans,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored, before);
|
||||
assert_eq!(spans[1].kind, SyntaxKind::Number);
|
||||
restored.push(Scope::new("keyword.operator.python").unwrap());
|
||||
assert_eq!(classify(&restored), Some(SyntaxKind::Keyword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_parser_errors_clear_the_entire_buffer_and_recover_on_an_edit() {
|
||||
let path = Some(Path::new("sample.rs"));
|
||||
let mut source = lines("fn main() {}\nlet value = 42;");
|
||||
let mut cache = SyntaxCache::new();
|
||||
cache.update(&source, path, 0);
|
||||
assert!(!cache.line(0).is_empty());
|
||||
// Missing grammar contexts make Syntect return a genuine ParsingError, not a mock failure.
|
||||
cache.parse_from(0, cache.syntax.unwrap(), &SyntaxSet::new());
|
||||
assert!(cache.error().unwrap().contains("Missing context"));
|
||||
assert!(cache.error().unwrap().contains("line 1"));
|
||||
assert!(cache.line(0).is_empty());
|
||||
assert!(cache.line(1).is_empty());
|
||||
cache.update(&source, path, 1);
|
||||
assert!(cache.error().is_some());
|
||||
source[1] = "let value = 43;".into();
|
||||
cache.update(&source, path, 2);
|
||||
assert_eq!(cache.error(), None);
|
||||
assert_token(&cache, &source, 0, "fn", SyntaxKind::Keyword);
|
||||
assert_token(&cache, &source, 1, "43", SyntaxKind::Number);
|
||||
assert_fresh(&cache, &source, path);
|
||||
}
|
||||
}
|
||||
+512
-51
@@ -30,6 +30,7 @@ use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
session::EditorSession,
|
||||
syntax::{SyntaxCache, SyntaxKind},
|
||||
};
|
||||
|
||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
||||
@@ -115,13 +116,16 @@ impl Drop for TerminalSession {
|
||||
#[derive(Default)]
|
||||
struct BufferViewports {
|
||||
offsets: BTreeMap<usize, (usize, usize)>,
|
||||
syntax: BTreeMap<usize, SyntaxCache>,
|
||||
}
|
||||
|
||||
impl BufferViewports {
|
||||
fn draw(&mut self, frame: &mut Frame, editor: &Editor) {
|
||||
self.offsets.retain(|id, _| editor.has_buffer(*id));
|
||||
self.syntax.retain(|id, _| editor.has_buffer(*id));
|
||||
let (top, left) = self.offsets.entry(editor.buffer_id()).or_default();
|
||||
draw(frame, editor, top, left);
|
||||
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
|
||||
draw(frame, editor, top, left, syntax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +220,14 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
|
||||
visible
|
||||
}
|
||||
|
||||
fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
fn draw(
|
||||
frame: &mut Frame,
|
||||
editor: &Editor,
|
||||
top: &mut usize,
|
||||
left: &mut usize,
|
||||
syntax: &mut SyntaxCache,
|
||||
) {
|
||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -291,6 +302,49 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
.map(|line| Line::from(visible_line(line, *left, width)))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), viewport);
|
||||
let buffer = frame.buffer_mut();
|
||||
for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) {
|
||||
let spans = syntax.line(row);
|
||||
if spans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut span_index = 0;
|
||||
let mut column = 0;
|
||||
let right = left.saturating_add(width);
|
||||
for (byte, grapheme) in line.grapheme_indices(true) {
|
||||
if column >= right {
|
||||
break;
|
||||
}
|
||||
let cells = if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
};
|
||||
let end = column + cells;
|
||||
while span_index < spans.len() && spans[span_index].end_byte <= byte {
|
||||
span_index += 1;
|
||||
}
|
||||
if let Some(span) = spans.get(span_index)
|
||||
&& span.start_byte <= byte
|
||||
&& end > *left
|
||||
&& (grapheme == "\t" || (column >= *left && end <= right))
|
||||
{
|
||||
let color = match span.kind {
|
||||
SyntaxKind::Comment => Color::DarkGray,
|
||||
SyntaxKind::String => Color::Green,
|
||||
SyntaxKind::Keyword => Color::Magenta,
|
||||
SyntaxKind::Number => Color::Yellow,
|
||||
SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan,
|
||||
SyntaxKind::Function => Color::LightBlue,
|
||||
};
|
||||
let y = viewport.y + (row - *top) as u16;
|
||||
for cell in column.max(*left)..end.min(right) {
|
||||
buffer[(viewport.x + (cell - *left) as u16, y)].set_fg(color);
|
||||
}
|
||||
}
|
||||
column = end;
|
||||
}
|
||||
}
|
||||
if let Some(found) = editor.search_match()
|
||||
&& found.start.row >= *top
|
||||
&& found.start.row < top.saturating_add(height)
|
||||
@@ -380,6 +434,11 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
if let Some(count) = editor.count() {
|
||||
write!(status, " count: {count}").unwrap();
|
||||
}
|
||||
if let Some(error) = syntax.error() {
|
||||
write!(status, " syntax: {error}").unwrap();
|
||||
} else if let Some(language) = syntax.language() {
|
||||
write!(status, " {language}").unwrap();
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
status_area,
|
||||
@@ -786,6 +845,387 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.file("number.rs", "42");
|
||||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
|
||||
let revision = editor.text_revision();
|
||||
editor.handle(Input::Right);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.lines(), &["42"]);
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
|
||||
|
||||
editor.handle(Input::Home);
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Char('c'));
|
||||
for ch in "\"ok\"".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.lines(), &["\"ok\""]);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
|
||||
assert!(editor.text_revision() > revision);
|
||||
editor.handle(Input::Char('u'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.lines(), &["42"]);
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
|
||||
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"fn main() { let n = 42; let s = \"ok\"; } // note".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Home);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let source = &editor.lines()[0];
|
||||
for (token, color) in [
|
||||
("fn", Color::Magenta),
|
||||
("main", Color::LightBlue),
|
||||
("let", Color::Magenta),
|
||||
("42", Color::Yellow),
|
||||
("\"ok\"", Color::Green),
|
||||
] {
|
||||
let x = 2 + source.find(token).unwrap() as u16;
|
||||
assert_eq!(buffer[(x, 0)].fg, color, "{token}");
|
||||
assert_eq!(buffer[(x, 0)].bg, Color::Reset);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() {
|
||||
let directory = Directory::new();
|
||||
for (name, before, after, color) in [
|
||||
(
|
||||
"comment.rs",
|
||||
"/*\nhidden\nvisible\n*/",
|
||||
"//\nhidden\nvisible\n//",
|
||||
Color::DarkGray,
|
||||
),
|
||||
(
|
||||
"string.py",
|
||||
"text = \"\"\"\nhidden\nvisible\n\"\"\"",
|
||||
"text = \"\"\nhidden\nvisible\n\"\"",
|
||||
Color::Green,
|
||||
),
|
||||
] {
|
||||
let path = directory.file(name, before);
|
||||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Down);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2);
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v");
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}");
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(after.into())])
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v");
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(2, 0)].fg,
|
||||
Color::Reset,
|
||||
"{name}"
|
||||
);
|
||||
editor.handle(Input::Char('u'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_colors_whole_unicode_graphemes_and_clipped_tabs_without_painting_wide_blanks() {
|
||||
let directory = Directory::new();
|
||||
let source = "#a\t界e\u{301}👩🚀z";
|
||||
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for (offset, width, caret, colors) in [
|
||||
(2, 5, 4, vec![Color::DarkGray; 5]),
|
||||
(5, 2, 4, vec![Color::Reset, Color::DarkGray]),
|
||||
(7, 2, 5, vec![Color::DarkGray; 2]),
|
||||
(8, 2, 6, vec![Color::Reset, Color::DarkGray]),
|
||||
(
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
vec![
|
||||
Color::DarkGray,
|
||||
Color::DarkGray,
|
||||
Color::DarkGray,
|
||||
Color::DarkGray,
|
||||
Color::Reset,
|
||||
],
|
||||
),
|
||||
] {
|
||||
editor.handle(Input::Home);
|
||||
for _ in 0..caret {
|
||||
editor.handle(Input::Right);
|
||||
}
|
||||
let area = Rect::new(3, 2, width + 2, 4);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(20, 8),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, offset);
|
||||
let rendered = terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap()
|
||||
.buffer
|
||||
.clone();
|
||||
assert_eq!(left, offset);
|
||||
let text_area = Rect::new(area.x + 2, area.y, width, 1);
|
||||
let mut expected = ratatui::buffer::Buffer::empty(text_area);
|
||||
use ratatui::widgets::Widget;
|
||||
Paragraph::new(visible_line(source, offset, usize::from(width)))
|
||||
.render(text_area, &mut expected);
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (cell, color) in colors.into_iter().enumerate() {
|
||||
let position = (text_area.x + cell as u16, text_area.y);
|
||||
assert_eq!(buffer[position].symbol(), expected[position].symbol());
|
||||
assert_eq!(rendered[position].fg, color, "left={offset}, cell={cell}");
|
||||
assert_eq!(rendered[position].bg, Color::Reset);
|
||||
}
|
||||
assert_eq!(buffer[(area.x, area.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].fg, Color::Reset);
|
||||
assert_eq!(editor.lines(), &[source]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_visual_selection_and_secondary_carets_override_syntax_foreground() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = Editor::open(
|
||||
Some(directory.file("overlays.py", "# word\n# word\n# word")),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
for ch in "word".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let found = editor.search_match().unwrap();
|
||||
let y = found.start.row as u16;
|
||||
for x in 4..8 {
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black);
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow);
|
||||
}
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Char('e'));
|
||||
editor.handle(Input::Ctrl('a'));
|
||||
assert_eq!(editor.selection_count(), 3);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for selection in editor.selections() {
|
||||
let y = selection.start.row as u16;
|
||||
let start =
|
||||
display_column(&editor.lines()[selection.start.row], selection.start.column);
|
||||
let end = display_column(&editor.lines()[selection.end.row], selection.end.column);
|
||||
for column in start..end {
|
||||
let x = 2 + column as u16;
|
||||
if buffer[(x, y)].bg == Color::Magenta {
|
||||
assert_eq!(buffer[(x, y)].fg, Color::Black);
|
||||
} else {
|
||||
assert_eq!(buffer[(x, y)].fg, Color::White);
|
||||
assert_eq!(buffer[(x, y)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(2, y)].fg, Color::DarkGray);
|
||||
}
|
||||
for caret in editor
|
||||
.carets()
|
||||
.into_iter()
|
||||
.filter(|caret| *caret != editor.cursor())
|
||||
{
|
||||
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
|
||||
assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black);
|
||||
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() {
|
||||
let directory = Directory::new();
|
||||
let mut editor =
|
||||
Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
|
||||
let revision = editor.text_revision();
|
||||
for name in ["source.vampunknown", "source.txt", "again.rs"] {
|
||||
editor.execute(&format!("w {}", directory.0.join(name).display()));
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
if name.ends_with(".rs") {
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
|
||||
assert_eq!(
|
||||
viewports.syntax[&editor.buffer_id()].language(),
|
||||
Some("Rust")
|
||||
);
|
||||
} else {
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset);
|
||||
assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty());
|
||||
assert!(viewports.syntax[&editor.buffer_id()].language().is_none());
|
||||
}
|
||||
assert_eq!(editor.lines(), &["let value = 42;"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
|
||||
editor.execute(&format!(
|
||||
"edit {}",
|
||||
directory.file("b.py", "# note").display()
|
||||
));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray);
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace("\"ok\"".into())])
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
|
||||
assert_eq!(viewports.syntax[&a_id].language(), Some("Rust"));
|
||||
assert_eq!(viewports.syntax[&b_id].language(), Some("Python"));
|
||||
editor.execute(&format!("buffer {b_id}"));
|
||||
editor.execute(&format!("bdelete {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
|
||||
assert!(!viewports.syntax.contains_key(&a_id));
|
||||
assert!(!viewports.offsets.contains_key(&a_id));
|
||||
assert_eq!(viewports.syntax.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_editor_text_stays_out_of_docked_and_floating_picker_surfaces() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.file("panels.rs", &vec!["// note"; 30].join("\n"));
|
||||
let area = Rect::new(3, 2, 100, 24);
|
||||
for (name, side) in [
|
||||
("left", Some(DockSide::Left)),
|
||||
("right", Some(DockSide::Right)),
|
||||
("top", Some(DockSide::Top)),
|
||||
("bottom", Some(DockSide::Bottom)),
|
||||
("floating", None),
|
||||
] {
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
if side.is_some() {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
}
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(if side.is_some() { 'e' } else { 'o' }));
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(110, 30),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (text, panel) = if let Some(side) = side {
|
||||
let (text, panel) = dock_layout(workspace, side, true);
|
||||
(text, panel.unwrap())
|
||||
} else {
|
||||
(workspace, Rect::new(area.x + 8, area.y + 3, 84, 18))
|
||||
};
|
||||
let mut expected = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(110, 30),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(panel),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
expected
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
editor.file_picker().unwrap(),
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let expected = expected.backend().buffer();
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert_eq!(buffer[(x, y)], expected[(x, y)], "{name}: {x},{y}");
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(text.x + 3, text.y)].symbol(), "/");
|
||||
assert_eq!(buffer[(text.x + 3, text.y)].fg, Color::DarkGray);
|
||||
assert_eq!(buffer[(text.x, text.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].fg, Color::Reset);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
|
||||
for (code, modifiers, expected) in [
|
||||
@@ -879,11 +1319,12 @@ mod tests {
|
||||
editor.set_file_explorer_side("bottom").unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for key in ['o', 'e'] {
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(key));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
@@ -892,7 +1333,7 @@ mod tests {
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
@@ -1362,8 +1803,9 @@ mod tests {
|
||||
.collect();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let query_cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(screen_text(&terminal).contains(&format!("> {query}")));
|
||||
@@ -1384,7 +1826,7 @@ mod tests {
|
||||
filtered
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&format!("filter: {query}")), "{text}");
|
||||
@@ -1405,7 +1847,7 @@ mod tests {
|
||||
editor.handle(Input::Char('i'));
|
||||
assert_eq!(editor.file_picker().unwrap().query, query);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor);
|
||||
editor.handle(Input::Escape);
|
||||
@@ -1417,7 +1859,7 @@ mod tests {
|
||||
Path::new(query)
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(
|
||||
@@ -1437,7 +1879,7 @@ mod tests {
|
||||
Path::new(query)
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(cursor.y, query_cursor.y);
|
||||
@@ -1455,7 +1897,7 @@ mod tests {
|
||||
Path::new(query)
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), cursor);
|
||||
editor.handle(Input::Ctrl('t'));
|
||||
@@ -1481,6 +1923,7 @@ mod tests {
|
||||
editor.set_project_root(directory.0.clone());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
editor.handle(Input::Char('i'));
|
||||
@@ -1495,7 +1938,7 @@ mod tests {
|
||||
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("List"));
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
@@ -1504,7 +1947,7 @@ mod tests {
|
||||
assert!(editor.file_picker().unwrap().searching);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
editor.handle(Input::Escape);
|
||||
@@ -1516,7 +1959,7 @@ mod tests {
|
||||
assert!(!editor.file_picker().unwrap().searching);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("filter: n"));
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
@@ -1549,6 +1992,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] {
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(key));
|
||||
@@ -1567,7 +2011,7 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(editor.file_picker().unwrap().view, view);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(editor.file_navigation_hint()), "{text}");
|
||||
@@ -1623,7 +2067,7 @@ mod tests {
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains(editor.selection_navigation_hint()));
|
||||
}
|
||||
@@ -1648,6 +2092,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
@@ -1659,7 +2104,7 @@ mod tests {
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, true);
|
||||
@@ -1675,7 +2120,7 @@ mod tests {
|
||||
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
|
||||
let buffer = terminal.backend().buffer();
|
||||
@@ -1727,14 +2172,15 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let before = (top, left);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
||||
assert_eq!((top, left), before);
|
||||
@@ -1855,6 +2301,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
@@ -1876,7 +2323,7 @@ mod tests {
|
||||
terminal.resize(area).unwrap();
|
||||
let area = terminal.get_frame().area();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let workspace = Rect::new(
|
||||
@@ -1894,7 +2341,7 @@ mod tests {
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
@@ -1940,15 +2387,16 @@ mod tests {
|
||||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('b'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("leader: Space b"));
|
||||
editor.handle(Input::Char('x'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("leader:"));
|
||||
assert_eq!(editor.lines(), &["keep"]);
|
||||
@@ -1966,8 +2414,9 @@ mod tests {
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains(", b n"));
|
||||
assert!(!screen_text(&terminal).contains("Space b n"));
|
||||
@@ -1975,7 +2424,7 @@ mod tests {
|
||||
.bind("normal", ",", Some("core.command".into()))
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("unbound"));
|
||||
assert!(!screen_text(&terminal).contains(", b n"));
|
||||
@@ -2214,16 +2663,17 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
editor.handle(Input::Char('1'));
|
||||
editor.handle(Input::Char('0'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("count: 10"));
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Down);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("count:"));
|
||||
assert_eq!(
|
||||
@@ -2253,8 +2703,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!((top, left), (8, 8));
|
||||
let buffer = terminal.backend().buffer();
|
||||
@@ -2272,7 +2723,7 @@ mod tests {
|
||||
);
|
||||
editor.handle(Input::Down);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect();
|
||||
@@ -2285,7 +2736,7 @@ mod tests {
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Up);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] {
|
||||
@@ -2295,7 +2746,7 @@ mod tests {
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] {
|
||||
@@ -2308,6 +2759,7 @@ mod tests {
|
||||
fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for count in [9usize, 10, 100] {
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
@@ -2320,7 +2772,7 @@ mod tests {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
@@ -2345,7 +2797,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "1");
|
||||
@@ -2370,8 +2822,9 @@ mod tests {
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> no-such-command"), "{text}");
|
||||
@@ -2384,7 +2837,7 @@ mod tests {
|
||||
assert_eq!(editor.mode(), Mode::Search);
|
||||
assert_eq!(editor.command_line(), "saved search");
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("/saved search"));
|
||||
}
|
||||
@@ -2404,8 +2857,9 @@ mod tests {
|
||||
let first_name = palette.entries[0].name.clone();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&selected.name), "{text}");
|
||||
@@ -2439,8 +2893,9 @@ mod tests {
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}");
|
||||
@@ -2452,7 +2907,7 @@ mod tests {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("sample | unbound | NORMAL"), "{text}");
|
||||
@@ -2549,18 +3004,19 @@ mod tests {
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let viewport = (top, left);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!((top, left), viewport);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!((top, left), viewport);
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
@@ -2582,6 +3038,7 @@ mod tests {
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::End);
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for prompting in [false, true] {
|
||||
if prompting {
|
||||
editor.handle(Input::Char('/'));
|
||||
@@ -2599,7 +3056,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
@@ -2645,8 +3102,9 @@ mod tests {
|
||||
assert_eq!(editor.selections().len(), 5);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for selection in editor.selections() {
|
||||
@@ -2676,8 +3134,9 @@ mod tests {
|
||||
editor.handle(Input::Char('A'));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for caret in editor.carets() {
|
||||
@@ -2714,8 +3173,9 @@ mod tests {
|
||||
editor.handle(Input::Left);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for x in [1, 2, 3, 4] {
|
||||
@@ -2726,7 +3186,7 @@ mod tests {
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Down);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(10, 0)].symbol(), " ");
|
||||
@@ -2740,7 +3200,7 @@ mod tests {
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(left, 4);
|
||||
@@ -2753,7 +3213,7 @@ mod tests {
|
||||
assert_ne!(buffer[(2, 1)].bg, Color::Blue);
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
|
||||
@@ -2778,8 +3238,9 @@ mod tests {
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(left, 4);
|
||||
@@ -2796,7 +3257,7 @@ mod tests {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(1, 4)].symbol(), "界");
|
||||
@@ -2805,7 +3266,7 @@ mod tests {
|
||||
editor.handle(Input::Backspace);
|
||||
editor.handle(Input::Char('\t'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
|
||||
Reference in New Issue
Block a user