makepad/code_editor/tests/editor_apis/mod.rs
Admin 53e0e85b92 draw and widgets: Slug text everywhere, no blank glyphs, slimmer widgets
All outline text draws with Slug curves on every platform (SDF/MSDF stays
selectable per size); a frame never presents a blank glyph and retained draw
lists keep theirs. One coverage model for vector feathering, widget families
split out of the core crate, fewer steady-state allocations, video keeps its
aspect (Contain/Cover), render and SSAO pass unification.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:26:21 +02:00

932 lines
38 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
use crate::{
decoration::DecorationSet,
document::{AnchorRange, CodeDocument, PreparedDocument},
history::EditKind,
selection::SelectionSet,
session::{PreparedView, PreparedViewError, SessionId},
text::{Change, Drift, Edit, Length},
};
fn pos(line_index: usize, byte_index: usize) -> Position {
Position {
line_index,
byte_index,
}
}
fn doc(source: &str) -> CodeDocument {
CodeDocument::new(source.into(), DecorationSet::default())
}
fn edit(document: &CodeDocument, change: Change) {
document.edit_selections(
SessionId::default(),
EditKind::Other,
&SelectionSet::new(),
&Settings::default(),
|mut editor, _, _| {
editor.apply_edit(Edit {
change: change.clone(),
drift: Drift::Before,
});
},
);
}
fn editor() -> (Cx, CodeEditor) {
let mut cx = Cx::new(Box::new(|_, _| {}));
let editor = cx.with_vm(|vm| {
makepad_widgets_core::script_mod(vm);
crate::script_mod(vm);
vm.capture_errors();
let value = script_eval!(vm, { use mod.widgets.* CodeEditor{} });
let editor = CodeEditor::script_from_value(vm, value);
assert!(vm.take_errors().is_empty());
editor
});
(cx, editor)
}
fn measured_editor() -> (Cx, CodeEditor) {
let (cx, mut editor) = editor();
editor.cell_size = dvec2(8.0, 16.0);
editor.ascent = 10.0;
editor.descent = 3.0;
editor.cell_offset_y = 1.5;
editor.pad_left_top = dvec2(0.0, 0.0);
editor.show_gutter = false;
editor.viewport_rect = Rect {
pos: dvec2(100.0, 50.0),
size: dvec2(600.0, 300.0),
};
editor.unscrolled_rect = editor.viewport_rect;
(cx, editor)
}
#[test]
fn anchors_follow_shared_insertions_and_deletion() {
let document = doc("abcdef");
let before = document.create_anchor(pos(0, 2), Drift::Before);
let after = document.create_anchor(pos(0, 2), Drift::After);
let clone = document.clone();
edit(&clone, Change::Insert(pos(0, 0), "x".into()));
assert_eq!(document.resolve_anchor(before), Some(pos(0, 3)));
edit(&clone, Change::Insert(pos(0, 3), "yz".into()));
assert_eq!(document.resolve_anchor(before), Some(pos(0, 3)));
assert_eq!(document.resolve_anchor(after), Some(pos(0, 5)));
edit(&clone, Change::Insert(pos(0, 8), "z".into()));
assert_eq!(document.resolve_anchor(after), Some(pos(0, 5)));
edit(
&clone,
Change::Delete(
pos(0, 1),
Length {
line_count: 0,
byte_count: 6,
},
),
);
assert_eq!(document.resolve_anchor(before), Some(pos(0, 1)));
assert_eq!(document.resolve_anchor(after), Some(pos(0, 1)));
assert_eq!(doc("abc").resolve_anchor(before), None);
document.remove_anchor(before);
assert_eq!(clone.resolve_anchor(before), None);
}
#[test]
fn anchors_follow_multiline_edits_and_shared_undo() {
let document = doc("abc\ndef\nghi");
let anchor = document.create_anchor(pos(2, 1), Drift::After);
let mut session = CodeSession::new(document.clone());
session.set_selection(
pos(0, 0),
Affinity::After,
SelectionMode::Simple,
NewGroup::Yes,
);
session.paste("z\n".into());
session.handle_changes();
assert_eq!(document.resolve_anchor(anchor), Some(pos(3, 1)));
assert!(session.undo());
session.handle_changes();
assert_eq!(document.resolve_anchor(anchor), Some(pos(2, 1)));
edit(
&document,
Change::Delete(
pos(0, 2),
Length {
line_count: 2,
byte_count: 2,
},
),
);
assert_eq!(document.resolve_anchor(anchor), Some(pos(0, 2)));
}
#[test]
fn range_retains_document_positions_and_follows_shared_edits() {
let document = doc("prefix\nab界cd\nefgh\ntail");
let anchors = AnchorRange {
start: document.create_anchor(pos(1, 2), Drift::Before),
end: document.create_anchor(pos(2, 2), Drift::After),
};
let mut session = CodeSession::new(document.clone());
session.set_view_range(Some(anchors));
assert_eq!(session.layout().view_line_range(), 1..3);
assert_eq!(session.layout().line_byte_range(1), 2..7);
assert_eq!(session.layout().line_byte_range(2), 0..2);
assert_eq!(session.layout().line(1).y(), 0.0);
assert_eq!(session.layout().height(), 2.0);
assert_eq!(
session
.layout()
.logical_to_normalized_position(pos(1, 5), Affinity::After),
(4.0, 0.0)
);
edit(&document, Change::Insert(pos(0, 0), "new\n".into()));
edit(&document, Change::Insert(pos(2, 5), "X".into()));
session.handle_changes();
assert_eq!(session.view_range(), Some(pos(2, 2)..pos(3, 2)));
assert_eq!(session.layout().view_line_range(), 2..4);
assert_eq!(session.layout().line(2).y(), 0.0);
session.set_view_range(None);
assert_eq!(session.layout().line(2).y(), 2.0);
}
#[test]
fn range_excludes_end_line_and_clamps_selection() {
let document = doc("one\ntwo\nthree");
let mut session = CodeSession::new(document.clone());
session.set_view_range(Some(AnchorRange {
start: document.create_anchor(pos(1, 1), Drift::Before),
end: document.create_anchor(pos(2, 0), Drift::After),
}));
assert_eq!(session.layout().view_line_range(), 1..2);
assert_eq!(session.layout().height(), 1.0);
session.set_selection(pos(0, 0), Affinity::After, SelectionMode::All, NewGroup::No);
assert_eq!(session.selections()[0].start(), pos(1, 1));
assert_eq!(session.selections()[0].end(), pos(2, 0));
edit(
&document,
Change::Delete(
pos(0, 0),
Length {
line_count: 2,
byte_count: 2,
},
),
);
session.handle_changes();
assert_eq!(session.view_range(), Some(pos(0, 0)..pos(0, 0)));
assert_eq!(session.layout().height(), 1.0);
}
#[test]
fn prepared_document_and_view_are_send_and_attach_without_relayout() {
fn assert_send<T: Send>() {}
assert_send::<PreparedDocument>();
assert_send::<PreparedView>();
let (prepared, view) = std::thread::spawn(|| {
let prepared = CodeDocument::prepare("// hello\nlet v = vec![界];".into());
let view = CodeSession::prepare_view(
&prepared,
Some(pos(1, 0)..pos(1, prepared.as_text().as_lines()[1].len())),
);
(prepared, view)
})
.join()
.unwrap();
let digest = prepared.digest();
let text_pointer = prepared.as_text().as_lines()[1].as_ptr();
let document = CodeDocument::from_prepared(prepared);
assert_eq!(document.digest(), digest);
assert_eq!(document.as_text().as_lines()[1].as_ptr(), text_pointer);
let session = CodeSession::from_prepared(document.clone(), view).unwrap();
assert_eq!(session.layout().line(1).y(), 0.0);
assert_eq!(
session.layout().line(1).width(),
document.as_text().as_lines()[1].column_count() as f64
);
assert!(document.layout().tokens[1]
.iter()
.any(|token| token.kind == TokenKind::Macro));
}
#[test]
fn prepared_view_rejects_wrong_digest_edited_version_and_invalid_range() {
let prepared = CodeDocument::prepare("abc".into());
let view = CodeSession::prepare_view(&prepared, None);
assert_eq!(
CodeSession::from_prepared(doc("xyz"), view.clone()).unwrap_err(),
PreparedViewError::Stale
);
let document = CodeDocument::from_prepared(prepared.clone());
document.replace("abc".into());
assert_eq!(document.digest(), prepared.digest());
assert_eq!(
CodeSession::from_prepared(document, view).unwrap_err(),
PreparedViewError::Stale
);
let invalid = CodeSession::prepare_view(&prepared, Some(pos(0, 2)..pos(0, 8)));
assert_eq!(
CodeSession::from_prepared(CodeDocument::from_prepared(prepared), invalid).unwrap_err(),
PreparedViewError::InvalidRange
);
}
#[test]
fn hit_test_matches_wide_utf8_cells_scroll_and_range() {
let (_cx, mut editor) = measured_editor();
let document = doc("hidden\na界\tz!");
let mut session = CodeSession::new(document.clone());
session.set_view_range(Some(AnchorRange {
start: document.create_anchor(pos(1, 1), Drift::Before),
end: document.create_anchor(pos(1, 6), Drift::After),
}));
let rect = editor.position_rect(&session, pos(1, 1)).unwrap();
assert_eq!(rect.pos, dvec2(8.0, 0.0));
assert_eq!(rect.size, dvec2(16.0, 16.0));
let hit = editor
.hit_test(&session, rect.pos + rect.size * 0.75)
.unwrap();
assert_eq!(hit.position, pos(1, 4));
assert_eq!(hit.grapheme_range, pos(1, 1)..pos(1, 4));
assert_eq!(hit.rect, rect);
assert!(editor.position_rect(&session, pos(1, 2)).is_none());
assert!(editor.position_rect(&session, pos(1, 6)).is_none());
assert!(editor.hit_test(&session, dvec2(4.0, 8.0)).is_none());
assert!(editor.hit_test(&session, dvec2(100.0, 8.0)).is_none());
// Draw's viewport origin includes scrolling; public coordinates are outer-local.
editor.viewport_rect.pos.x -= 4.0;
assert_eq!(
editor.position_rect(&session, pos(1, 1)).unwrap().pos.x,
4.0
);
assert_eq!(
editor.hit_test(&session, dvec2(5.0, 8.0)).unwrap().position,
pos(1, 1)
);
}
#[test]
fn base_font_scale_multiplies_fold_scale_without_frame_contamination() {
let (_cx, mut editor) = measured_editor();
let session = CodeSession::new(doc(" folded\nnext"));
let before = editor.metrics(&session);
editor.set_font_scale(0.5);
let scaled = editor.metrics(&session);
assert_eq!(scaled.line_advance, before.line_advance * 0.5);
assert_eq!(scaled.column_advance, before.column_advance * 0.5);
assert_eq!(scaled.ascent, before.ascent * 0.5);
assert_eq!(scaled.descent, before.descent * 0.5);
session.fold();
while session.update_folds() {}
assert_eq!(
editor.position_rect(&session, pos(0, 8)).unwrap().size.y,
0.8
);
editor.draw_text.font_scale = editor.base_font_scale * 0.1;
editor.draw_gutter.font_scale = editor.base_font_scale * 0.1;
editor.reset_draw_font_scale();
assert_eq!(editor.draw_text.font_scale, 0.5);
assert_eq!(editor.draw_gutter.font_scale, 0.5);
assert_eq!(editor.metrics(&session), scaled);
editor.set_font_scale(f32::NAN);
assert_eq!(editor.metrics(&session), scaled);
}
#[test]
fn wrapped_range_rebases_first_visible_row_and_keeps_global_geometry() {
let (_cx, editor) = measured_editor();
let document = doc("one two three four\nlast");
let mut session = CodeSession::new(document.clone());
session.set_wrap_column(Some(8));
session.set_view_range(Some(AnchorRange {
start: document.create_anchor(pos(0, 8), Drift::Before),
end: document.create_anchor(pos(0, 13), Drift::After),
}));
assert_eq!(
editor.position_rect(&session, pos(0, 8)).unwrap().pos.y,
0.0
);
assert_eq!(session.layout().height(), 1.0);
assert_eq!(session.layout().width(), 5.0);
}
#[test]
fn macro_and_attribute_tokens_respect_lexical_context_and_incremental_edits() {
let document = doc("vec![] foo ![]\n#![cfg(any(\n feature = \"x]y\", /* ] */ test\n))]\nfn f() {} // #[x] foo!\n\"#[x] foo!\"");
let kinds = document.layout().tokens[0]
.iter()
.map(|token| token.kind)
.collect::<Vec<_>>();
assert_eq!(kinds[0], TokenKind::Macro);
assert!(kinds.contains(&TokenKind::Identifier));
for line in 1..=3 {
assert!(document.layout().tokens[line]
.iter()
.all(|token| token.kind == TokenKind::Attribute));
}
assert_eq!(document.layout().tokens[4][0].kind, TokenKind::OtherKeyword);
assert_eq!(document.layout().tokens[5][0].kind, TokenKind::String);
edit(
&document,
Change::Delete(
pos(1, 0),
Length {
line_count: 0,
byte_count: 1,
},
),
);
assert!(document.layout().tokens[2]
.iter()
.any(|token| token.kind != TokenKind::Attribute));
for (line, tokens) in document
.as_text()
.as_lines()
.iter()
.zip(&document.layout().tokens)
{
assert_eq!(
line.len(),
tokens.iter().map(|token| token.len).sum::<usize>()
);
}
}
#[test]
fn read_only_blocks_return_typing_paste_ime_undo_redo_cut_and_drop() {
assert_mutation_events_refused(doc("unchanged"), true);
let prepared = CodeDocument::prepare_diff(&[
diff_row(DiffRowKind::Removed, Some(0), None, 1),
], "unchanged", "").unwrap();
assert_mutation_events_refused(CodeDocument::from_prepared_diff(prepared), false);
}
fn assert_mutation_events_refused(document: CodeDocument, explicit_read_only: bool) {
use std::sync::{Arc, Mutex};
let (mut cx, mut editor) = editor();
let mut session = CodeSession::new(document);
record_editor(&mut cx, &mut editor, &mut session);
editor.set_key_focus(&mut cx);
cx.action(());
cx.handle_actions();
assert!(cx.has_key_focus(editor.scroll_bars.area()));
session.set_selection(pos(0, 0), Affinity::After, SelectionMode::All, NewGroup::No);
let copy = TextClipboardEvent { response: Default::default() };
editor.handle_event(&mut cx, &Event::TextCopy(copy.clone()), &mut Scope::empty(), &mut session);
assert_eq!(copy.response.borrow().as_deref(), Some("unchanged"));
editor.blink_timer = cx.start_timeout(0.5);
editor.set_read_only(&mut cx, explicit_read_only);
if explicit_read_only { assert_eq!(editor.blink_timer.0, 0); }
let mut events = Vec::new();
for key_code in [
KeyCode::ReturnKey,
KeyCode::Tab,
KeyCode::Backspace,
KeyCode::Delete,
KeyCode::KeyZ,
KeyCode::KeyY,
KeyCode::KeyX,
KeyCode::KeyV,
] {
events.push(Event::KeyDown(KeyEvent {
key_code,
modifiers: KeyModifiers {
control: true,
..Default::default()
},
..Default::default()
}));
}
for (was_paste, replace_last) in [(false, false), (true, false), (false, true)] {
events.push(Event::TextInput(TextInputEvent {
input: "change".into(),
was_paste,
replace_last,
..Default::default()
}));
}
events.push(Event::TextRangeReplace(TextRangeReplaceEvent {
start: 0,
end: 1,
text: "IME".into(),
replaced_text: None,
fallback_to_insert: true,
}));
events.push(Event::TextCut(TextClipboardEvent {
response: Default::default(),
}));
events.push(Event::Drop(DropEvent {
modifiers: KeyModifiers::default(),
handled: Arc::new(Mutex::new(false)),
abs: dvec2(0.0, 0.0),
items: Arc::new(vec![DragItem::String {
value: "drop".into(),
internal_id: None,
}]),
}));
for event in events {
assert!(CodeEditor::is_mutation_event(&event));
assert!(editor
.handle_event(&mut cx, &event, &mut Scope::empty(), &mut session)
.is_empty());
assert_eq!(session.document().as_text().to_string(), "unchanged");
assert_eq!(session.document().version(), 0);
assert_eq!(editor.blink_timer.0, 0);
}
}
#[test]
fn steady_caret_never_rearms_blink_and_read_only_cancels_pending_timeout() {
let (mut cx, mut editor) = editor();
editor.reset_cursor_blinker(&mut cx);
assert_ne!(editor.blink_timer.0, 0);
editor.set_caret_policy(CaretPolicy::Steady);
assert_eq!(editor.blink_timer.0, 0);
editor.reset_cursor_blinker(&mut cx);
assert_eq!(editor.retired_blink_timer.0, 0);
assert_eq!(editor.blink_timer.0, 0);
editor.set_caret_policy(CaretPolicy::Blink);
editor.reset_cursor_blinker(&mut cx);
assert_ne!(editor.blink_timer.0, 0);
editor.set_read_only(&mut cx, true);
editor.reset_cursor_blinker(&mut cx);
assert_eq!(editor.blink_timer.0, 0);
}
#[test]
fn content_opacity_is_clamped_without_changing_background_or_theme() {
let (_cx, mut editor) = editor();
let bg = editor.draw_bg.color;
let gutter = editor.draw_gutter.color;
let text = editor.token_colors.identifier;
editor.set_content_opacity(0.25);
assert_eq!(editor.content_opacity, 0.25);
assert_eq!(editor.draw_bg.color, bg);
assert_eq!(editor.draw_gutter.color, gutter);
assert_eq!(editor.token_colors.identifier, text);
editor.set_content_opacity(-1.0);
assert_eq!(editor.content_opacity, 0.0);
editor.set_content_opacity(2.0);
editor.set_content_opacity(f32::NAN);
assert_eq!(editor.content_opacity, 1.0);
}
fn record_editor(cx: &mut Cx, editor: &mut CodeEditor, session: &mut CodeSession) {
let pass = DrawPass::new(cx);
pass.set_size(cx, dvec2(480.0, 200.0));
let mut list = DrawList2d::new(cx);
let event = DrawEvent::default();
let mut draw = CxDraw::new(cx, &event);
let mut cx = Cx2d::new(&mut draw);
cx.begin_pass(&pass, Some(1.0));
list.begin_always(&mut cx);
cx.begin_root_turtle(dvec2(480.0, 200.0), Layout::default());
editor.draw_walk_editor(&mut cx, session, Walk::fixed(480.0, 200.0));
cx.end_turtle();
list.end(&mut cx);
cx.end_pass(&pass);
}
#[test]
fn recorded_content_opacity_and_folded_last_line_keep_metrics_stable() {
let (mut cx, mut editor) = editor();
let mut session = CodeSession::new(doc("first\n folded"));
session.fold();
while session.update_folds() {}
session.set_selection(pos(0, 0), Affinity::After, SelectionMode::All, NewGroup::No);
editor.set_font_scale(0.5);
editor.set_content_opacity(0.25);
editor.set_caret_policy(CaretPolicy::Steady);
record_editor(&mut cx, &mut editor, &mut session);
let metrics = editor.metrics(&session);
assert!(metrics.line_advance > 0.0);
for vars in [
&editor.draw_selection.draw_vars,
&editor.draw_cursor.draw_vars,
&editor.draw_cursor_bg.draw_vars,
] {
let mut opacity = [-1.0];
vars.get_instance(&mut cx, live_id!(content_opacity), &mut opacity);
assert_eq!(opacity, [0.25]);
}
record_editor(&mut cx, &mut editor, &mut session);
assert_eq!(editor.metrics(&session), metrics);
assert_eq!(editor.draw_text.font_scale, 0.5);
assert_eq!(editor.draw_gutter.font_scale, 0.5);
assert_eq!(editor.blink_timer.0, 0);
}
fn grid_column(session: &CodeSession, byte_index: usize) -> usize {
session
.layout()
.line(0)
.logical_to_grid_position(byte_index, Affinity::After)
.1
}
#[test]
fn tab_advances_to_the_next_stop() {
use crate::char::CharExt;
assert_eq!('\t'.column_count(), 1);
assert_eq!('\t'.column_count_at(0, 4), 4);
assert_eq!('\t'.column_count_at(2, 4), 2);
assert_eq!('\t'.column_count_at(4, 4), 4);
assert_eq!('f'.column_count_at(0, 4), 1);
assert_eq!('界'.column_count(), 2);
let session = CodeSession::new(doc("\tfoo"));
assert_eq!(grid_column(&session, 1), 4, "f in \\tfoo is at column 4");
let mut session = CodeSession::new(doc("ab\tc"));
assert_eq!(grid_column(&session, 3), 4, "c in ab\\tc is at column 4 with tab width 4");
session.set_tab_column_count(8);
assert_eq!(grid_column(&session, 3), 8, "c in ab\\tc is at column 8 with tab width 8");
let session = CodeSession::new(doc("\tfoo"));
session.move_right(true);
let cursor = session.selections()[0].cursor;
assert_eq!(cursor.position, pos(0, 1));
assert_eq!(
grid_column(&session, cursor.position.byte_index),
4,
"cursor-right over a tab jumps to the tab stop"
);
let (_cx, editor) = measured_editor();
let session = CodeSession::new(doc("\tfoo"));
let hit = editor.hit_test(&session, dvec2(2.0, 8.0)).unwrap();
assert_eq!(hit.position, pos(0, 0));
assert_eq!(
grid_column(&session, hit.position.byte_index),
0,
"hit-test at pixel column 2 of a leading tab selects column 0"
);
assert_eq!(hit.rect.size.x, 32.0);
}
use crate::{
decoration::{Decoration, DecorationType, PreparedDecorationsError},
diff::{DiffEndpoint, DiffRowKind, DiffRowSpec, GutterMode, LineTerminator, PrepareDiffError, PreparedDiffDocument},
document::DocumentMutationError,
};
fn diff_row(kind: DiffRowKind, old_line: Option<u32>, new_line: Option<u32>, hunk: u32) -> DiffRowSpec {
DiffRowSpec { kind, old_line, new_line, hunk }
}
fn attach_diff(prepared: PreparedDiffDocument) -> CodeSession {
let view = CodeSession::prepare_view(prepared.document(), None);
CodeSession::from_prepared(CodeDocument::from_prepared_diff(prepared), view).unwrap()
}
#[test]
fn diff_endpoint_token_context_survives_removed_multiline_comment() {
let old = "/* removed\nlet retained = 1;\n*/\nfn after() {}\n";
let new = "let retained = 1;\nfn after() {}\n";
let specs = [
diff_row(DiffRowKind::Removed, Some(0), None, 1),
diff_row(DiffRowKind::Equal, Some(1), Some(0), 1),
diff_row(DiffRowKind::Removed, Some(2), None, 2),
diff_row(DiffRowKind::Equal, Some(3), Some(1), 2),
];
let prepared = CodeDocument::prepare_diff(&specs, old, new).unwrap();
let old_tokens = CodeDocument::prepare(old.into());
let new_tokens = CodeDocument::prepare(new.into());
for (row, spec) in specs.iter().enumerate() {
let expected = if let Some(line) = spec.new_line {
&new_tokens.layout().tokens[line as usize]
} else {
&old_tokens.layout().tokens[spec.old_line.unwrap() as usize]
};
assert_eq!(&prepared.document().layout().tokens[row], expected);
}
assert_eq!(prepared.document().layout().tokens[1][0].kind, TokenKind::OtherKeyword);
assert_eq!(prepared.document().layout().tokens[2][0].kind, TokenKind::Comment);
let combined = CodeDocument::prepare(prepared.as_text().clone());
assert_eq!(combined.layout().tokens[1][0].kind, TokenKind::Comment);
let tokens = prepared.document().layout().tokens.clone();
let mut session = attach_diff(prepared);
session.handle_changes();
assert_eq!(session.document().layout().tokens, tokens);
}
#[test]
fn diff_equal_terminators_refine_and_reconstruct_both_endpoints() {
let old = "α\t界\r\n\r\nlast\r\n";
let new = "α\t界\n\nlast";
let rows = (0..3).map(|line| diff_row(DiffRowKind::Equal, Some(line), Some(line), 7)).collect::<Vec<_>>();
let prepared = CodeDocument::prepare_diff(&rows, old, new).unwrap();
assert_eq!(prepared.row_count(), 6);
assert_eq!(prepared.row_count(), prepared.as_text().as_lines().len());
assert!(!prepared.as_text().to_string().contains('\r'));
assert_eq!(prepared.reconstruct(DiffEndpoint::Old).as_bytes(), old.as_bytes());
assert_eq!(prepared.reconstruct(DiffEndpoint::New).as_bytes(), new.as_bytes());
let meta = prepared.metadata();
assert!(meta.old_final_newline);
assert!(!meta.new_final_newline);
assert_eq!(meta.old_to_row, [0, 2, 4]);
assert_eq!(meta.new_to_row, [1, 3, 5]);
assert_eq!(meta.hunks.len(), 1);
assert_eq!((meta.hunks[0].removed, meta.hunks[0].added), (3, 3));
assert!(meta.rows.iter().all(|row| row.gutter_mark == Some(DecorationType::DiffChangedGutter)));
assert_eq!(meta.rows[0].old.as_ref().unwrap().terminator, LineTerminator::CrLf);
for row in &meta.rows {
for (source, text, map) in [(row.old.as_ref(), old, &meta.old_to_row), (row.new.as_ref(), new, &meta.new_to_row)] {
if let Some(source) = source {
let display_row = map[source.line as usize];
assert_eq!(&text[source.bytes.clone()], prepared.as_text().as_lines()[display_row]);
}
}
}
let session = attach_diff(prepared);
assert_eq!(session.layout().height(), 6.0);
assert_eq!(session.layout().line(0).width(), "α\t界".column_count_at(0, 4) as f64);
assert_eq!(grid_column(&session, "α\t".len()), 4);
assert_eq!(grid_column(&session, "α\t界".len()), 6);
}
#[test]
fn diff_empty_sentinel_and_final_newline_do_not_invent_source_rows() {
let empty = CodeDocument::prepare_diff(&[], "", "").unwrap();
assert_eq!(empty.row_count(), 1);
assert_eq!(empty.metadata().rows[0].kind, None);
assert!(empty.metadata().old_to_row.is_empty());
assert!(empty.metadata().new_to_row.is_empty());
assert!(empty.decorations().is_empty());
assert_eq!(empty.reconstruct(DiffEndpoint::Old), "");
assert_eq!(attach_diff(empty).layout().height(), 1.0);
for (old, new, kind, old_line, new_line) in [
("", "\r\n", DiffRowKind::Added, None, Some(0)),
("\n", "", DiffRowKind::Removed, Some(0), None),
("bare\r", "", DiffRowKind::Removed, Some(0), None),
("x\n", "x\n", DiffRowKind::Equal, Some(0), Some(0)),
] {
let prepared = CodeDocument::prepare_diff(&[diff_row(kind, old_line, new_line, 0)], old, new).unwrap();
assert_eq!(prepared.row_count(), 1);
assert_eq!(prepared.reconstruct(DiffEndpoint::Old), old);
assert_eq!(prepared.reconstruct(DiffEndpoint::New), new);
let session = attach_diff(prepared);
assert_eq!(session.document().as_text().as_lines().len(), 1);
assert_eq!(session.layout().height(), 1.0);
}
}
#[test]
fn diff_preparation_rejects_incomplete_or_invalid_maps() {
assert_eq!(CodeDocument::prepare_diff(&[], "x", "").unwrap_err(), PrepareDiffError::IncompleteEndpoints);
for rows in [
vec![diff_row(DiffRowKind::Removed, Some(1), None, 0)],
vec![diff_row(DiffRowKind::Added, Some(0), None, 0)],
vec![diff_row(DiffRowKind::Equal, Some(0), None, 0)],
vec![diff_row(DiffRowKind::Removed, Some(0), None, 0); 2],
] {
assert!(matches!(CodeDocument::prepare_diff(&rows, "x", ""), Err(PrepareDiffError::InvalidRow { .. })));
}
assert_eq!(CodeDocument::prepare_diff(&[diff_row(DiffRowKind::Equal, Some(0), Some(0), 0)], "x", "y").unwrap_err(),
PrepareDiffError::UnequalContent { row: 0 });
}
#[test]
fn diff_prepared_replacement_retains_runs_diagnostics_and_separate_hunks() {
let prepared = CodeDocument::prepare_diff(&[
diff_row(DiffRowKind::Removed, Some(0), None, 4),
diff_row(DiffRowKind::Removed, Some(1), None, 4),
diff_row(DiffRowKind::Added, None, Some(0), 4),
diff_row(DiffRowKind::Equal, Some(2), Some(1), 4),
], "a\n\nb", "c\nb").unwrap();
assert_eq!(prepared.decorations().len(), 2);
assert_eq!(prepared.decorations()[0].start(), pos(0, 0));
assert_eq!(prepared.decorations()[0].end(), pos(2, 0));
let mut replacement = prepared.decorations().to_vec();
replacement.push(Decoration::new(100, pos(3, 0), pos(3, 1), DecorationType::Warning));
let mut set = DecorationSet::new();
set.replace_prepared(replacement.clone()).unwrap();
let retained = set.clone();
assert_eq!(set.replace_prepared(vec![replacement[1], replacement[0]]),
Err(PreparedDecorationsError::OverlappingOrUnsorted));
assert_eq!(set, retained);
set.add_decoration(Decoration::new(101, pos(3, 0), pos(3, 1), DecorationType::Error));
assert_eq!(set.len(), 3);
assert_eq!(set[2].ty, DecorationType::Error);
let mut document = CodeDocument::from_prepared_diff(prepared);
let hunks = document.diff_metadata().unwrap().clone();
document.replace_prepared_decorations(replacement.clone()).unwrap();
assert_eq!(&*document.decorations(), &replacement);
assert_eq!(document.diff_metadata(), Some(&hunks));
assert_eq!(document.version(), 0);
}
#[test]
fn diff_blank_line_backgrounds_record_full_clipped_code_width() {
let prepared = CodeDocument::prepare_diff(&[
diff_row(DiffRowKind::Removed, Some(0), None, 1),
diff_row(DiffRowKind::Added, None, Some(0), 1),
diff_row(DiffRowKind::Added, None, Some(1), 1),
], "\n", "\n\n").unwrap();
let mut session = attach_diff(prepared);
let (mut cx, mut editor) = editor();
editor.show_gutter = false;
editor.word_wrap = false;
record_editor(&mut cx, &mut editor, &mut session);
for (vars, row) in [(&editor.draw_diff_removed.draw_vars, 0), (&editor.draw_diff_added.draw_vars, 2)] {
let rect = editor.diff_row_rect(&session, row).unwrap();
let mut size = [0.0; 2];
let mut position = [0.0; 2];
assert!(vars.get_instance_on_area(&cx, live_id!(rect_size), &mut size));
assert!(vars.get_instance_on_area(&cx, live_id!(rect_pos), &mut position));
let mut color = [0.0; 4];
assert!(vars.get_instance_on_area(&cx, live_id!(color), &mut color));
assert!((color[3] - 0.14).abs() < 0.001);
assert_eq!(size, [rect.size.x as f32, rect.size.y as f32]);
assert_eq!(position, [rect.pos.x as f32, rect.pos.y as f32]);
assert_eq!(size[0], (480.0 - editor.pad_left_top.x) as f32);
assert!(size[1] > 0.0);
}
assert!(editor.draw_cursor.draw_vars.area().is_valid(&cx));
let mut blink = [-1.0];
assert!(editor.draw_cursor.draw_vars.get_instance_on_area(&cx, live_id!(blink), &mut blink));
assert_eq!(blink, [0.0]);
assert_eq!(editor.blink_timer.0, 0);
editor.viewport_rect.pos.x -= 80.0;
let rect = editor.diff_row_rect(&session, 0).unwrap();
assert_eq!(rect.pos.x, editor.unscrolled_rect.pos.x + editor.pad_left_top.x);
assert_eq!(rect.size.x, 480.0 - editor.pad_left_top.x);
editor.show_gutter = true;
record_editor(&mut cx, &mut editor, &mut session);
assert_eq!(editor.gutter_chars, 8);
assert!(editor.draw_diff_changed.draw_vars.area().is_valid(&cx));
let diff_width = editor.metrics(&session).gutter_width;
session.document().set_gutter_mode(GutterMode::Plain);
assert!(editor.metrics(&session).gutter_width < diff_width);
assert!(session.document().is_read_only());
}
#[test]
fn diff_document_and_session_mutations_are_noops_but_removed_row_copy_works() {
let prepared = CodeDocument::prepare_diff(&[
diff_row(DiffRowKind::Removed, Some(0), None, 1),
diff_row(DiffRowKind::Added, None, Some(0), 1),
], "removed", "target").unwrap();
let mut session = attach_diff(prepared);
let document = session.document().clone();
assert_eq!(document.ensure_editable(), Err(DocumentMutationError::ReadOnlyDiff));
session.set_selection(pos(0, 0), Affinity::After, SelectionMode::Simple, NewGroup::No);
session.move_to(pos(0, 7), Affinity::After, NewGroup::No);
assert_eq!(session.copy(), "removed");
let selection = session.selections().to_vec();
let digest = document.digest();
let tokens = document.layout().tokens.clone();
let decorations = document.decorations().to_vec();
session.insert("(".into());
session.paste("paste".into());
session.paste_grouped("paste".into(), 42);
session.enter();
session.delete();
session.backspace();
session.indent();
session.outdent();
assert!(!session.undo());
assert!(!session.redo());
document.replace("replacement".into());
edit(&document, Change::Insert(pos(0, 0), "direct".into()));
document.edit_linewise(SessionId::default(), EditKind::Other, &SelectionSet::new(), |_, _| panic!("diff must refuse before invoking editor"));
document.force_new_group();
assert!(!document.undo(SessionId::default(), &SelectionSet::new()));
assert!(!document.redo(SessionId::default(), &SelectionSet::new()));
session.handle_changes();
assert_eq!(&*session.selections(), &selection);
assert_eq!(document.as_text().to_string(), "removed\ntarget");
assert_eq!(document.version(), 0);
assert_eq!(document.digest(), digest);
assert_eq!(document.layout().tokens, tokens);
assert_eq!(&*document.decorations(), &decorations);
}
#[test]
fn diff_admission_twenty_thousand_rows_off_thread() {
use std::time::Instant;
fn assert_send<T: Send>() {}
assert_send::<PreparedDiffDocument>();
let (prepared, view, prepare_ms) = std::thread::spawn(|| {
let old = "let old = 1;\n".repeat(10_000);
let new = "let new = 2;\n".repeat(10_000);
let rows = (0..10_000).flat_map(|line| [
diff_row(DiffRowKind::Removed, Some(line), None, line),
diff_row(DiffRowKind::Added, None, Some(line), line),
]).collect::<Vec<_>>();
let start = Instant::now();
let prepared = CodeDocument::prepare_diff(&rows, &old, &new).unwrap();
let view = CodeSession::prepare_view(prepared.document(), None);
let prepare_ms = start.elapsed().as_secs_f64() * 1000.0;
(prepared, view, prepare_ms)
}).join().unwrap();
let row_count = prepared.row_count();
assert_eq!(row_count, 20_000);
let text_ptr = prepared.as_text().as_lines().as_ptr();
let rows_ptr = prepared.metadata().rows.as_ptr();
let decorations_ptr = prepared.decorations().as_ptr();
let start = Instant::now();
let session = CodeSession::from_prepared(CodeDocument::from_prepared_diff(prepared), view).unwrap();
let attach_ms = start.elapsed().as_secs_f64() * 1000.0;
println!("record=diff_admission rows={row_count} prepare_ms={prepare_ms:.6} attach_ms={attach_ms:.6}");
assert!(attach_ms <= 1.0, "diff attachment exceeded 1 ms: {attach_ms}");
assert_eq!(session.document().as_text().as_lines().as_ptr(), text_ptr);
assert_eq!(session.document().diff_metadata().unwrap().rows.as_ptr(), rows_ptr);
assert_eq!(session.document().decorations().as_ptr(), decorations_ptr);
assert_eq!(session.layout().height(), row_count as f64);
}
/// Drawing from any first visible line lands every line where the layout
/// (and `position_rect`) puts it, block inlays included: the walk starts at
/// the top of the blocks reserved above that line, never below them.
#[test]
fn block_walk_from_any_start_line_agrees_with_line_y() {
use crate::inlays::BlockInlay;
use crate::widgets::BlockWidget;
use crate::layout::BlockElement;
let text: String = (0..40).map(|i| format!("line {i}\n")).collect();
let document = doc(&text);
document.set_block_inlays(vec![(1, BlockInlay::Widget(BlockWidget { height: 3.5 })), (7, BlockInlay::Widget(BlockWidget { height: 1.25 })), (20, BlockInlay::Widget(BlockWidget { height: 6.0 }))]);
let session = CodeSession::new(document.clone());
session.relayout();
let layout = session.layout();
let lines = layout.as_text().as_lines().len();
for start in 0..lines {
let mut y = layout.block_y(start);
let mut index = start;
for element in layout.block_elements(start, lines) {
match element {
BlockElement::Line { line, .. } => {
assert!((y - layout.line(index).y()).abs() < 1e-9, "from line {start}: line {index} walks to {y}, is at {}", layout.line(index).y());
y += line.height();
index += 1;
}
BlockElement::Widget(widget) => y += widget.height,
}
}
}
}
/// An inline inlay moves the text after it right by its columns, and an
/// edit before it carries it along with its text.
#[test]
fn inline_inlays_reserve_columns_and_follow_their_text() {
use crate::inlays::InlineInlay;
use crate::widgets::InlineWidget;
use crate::selection::Affinity;
let document = doc("fill: #ff5a36\nsize: 4\n");
document.set_inline_inlays(vec![(0, 6, InlineInlay::Widget(InlineWidget { column_count: 2 }))]);
let session = CodeSession::new(document.clone());
session.relayout_inline();
{
let layout = session.layout();
let line = layout.line(0);
assert_eq!(line.logical_to_grid_position(5, Affinity::After), (0, 5));
assert_eq!(line.logical_to_grid_position(6, Affinity::After), (0, 8));
assert_eq!(layout.line(1).logical_to_grid_position(6, Affinity::After), (0, 6));
}
// Two bytes typed before it: the inlay stays in front of the colour.
edit(&document, Change::Insert(pos(0, 0), " ".into()));
let layout = session.layout();
assert_eq!(layout.line(0).logical_to_grid_position(8, Affinity::After), (0, 10));
}
/// Every byte of every line maps to a grid position with inline inlays
/// anywhere (a line's start, its end, two at one byte, after a tab).
#[test]
fn inline_inlays_every_byte_has_a_grid_position() {
use crate::inlays::InlineInlay;
use crate::widgets::InlineWidget;
use crate::selection::Affinity;
let text = "Motion{layers: [\n\tText{text: \"Make it move\" align: @center}\n Rect{fill: #1d2a44}\n]}\n";
let document = doc(text);
let w = |n| InlineInlay::Widget(InlineWidget { column_count: n });
document.set_inline_inlays(vec![(1, 12, w(2)), (1, 42, w(2)), (1, 49, w(2)), (1, 49, w(3)), (2, 15, w(2)), (0, 0, w(1)), (3, 2, w(2))]);
let session = CodeSession::new(document.clone());
session.relayout_inline();
let layout = session.layout();
for (index, line_text) in text.split('\n').enumerate() {
let line = layout.line(index);
for byte in (0..=line_text.len()).filter(|b| line_text.is_char_boundary(*b)) {
for affinity in [Affinity::Before, Affinity::After] {
let ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| line.logical_to_grid_position(byte, affinity))).is_ok();
assert!(ok, "line {index} byte {byte} {affinity:?}");
}
}
}
}
#[test]
fn a_range_replace_keeps_the_given_indentation() {
// An outside change put in as given: typing's autoindent would move
// these lines (and `}` at column 0 once took 4 from 0).
let document = doc("let a = {\n x: 1\n}\nlet b = 2\n");
document.replace_range(pos(0, 8), pos(2, 1), "{\nx: 1,\n y: 2\n}".into());
assert_eq!(document.as_text().to_string(), "let a = {\nx: 1,\n y: 2\n}\nlet b = 2\n");
document.replace_range(pos(4, 8), pos(4, 9), "{\n}".into());
assert_eq!(document.as_text().to_string(), "let a = {\nx: 1,\n y: 2\n}\nlet b = {\n}\n");
}