makepad/code_editor/tests/editor_apis/mod.rs
Admin 2e27ce2ff1 platform: retained draw lists, shared publications and GPU residency (DL-0..DL-5)
Squash of 27 work commits (Sep 3–12):
  aa907ca  platform: a frame trace on both desktop backends, and a window that cannot present stops ticking at 600 Hz
  e3abf4d  map: the hosted-tile cache keeps the same ~2x-visible margin as the local one — a budget pinned at the visible set evicted the trailing edge of every pan on the next frame and refetched it a moment later
  8d542fe  platform: a child pass orphaned by its attaching draw list is no longer painted — the window's gauss_scene pass stayed a live_with_parent child after the map went flat and re-encoded a frozen 400-item list every pan frame with stale geometry ids (new tile meshes × old instance counts, tens of millions of triangles into a texture nobody read); make_child_pass records the recording list + redraw id, stale passes are skipped, a cached View re-attaches on a cache hit; tests for the orphan gate and the pool generation contract
  bb49fe6  map + platform: retained per-tile draw lists — each resident tile owns one DrawList2d per carto pass (fill, casing, stroke, icon, icon-high, shadow) and the label glyph batches are retained the same way, recorded when the bake, LOD ring, fringe/icon gates, flat/tilted or clip change and re-attached otherwise; a pan/zoom/tilt frame pushes this frame's uniforms onto the retained calls (DrawVars::update_uniforms_on_area, resolved slot table) and uploads zero instance bytes; the tilted per-pass depth is a pass_depth uniform; a held list's zbias resolves at entry (zbias_hold in every backend); the shimmer heartbeat patches shiny_time in place without a redraw; a freed/reused sub-list id is skipped by every draw-tree walker and the mask list re-records empty on the flat transition (contract test). Web pan tail 1,098 → 20 MiB/s, flat pan 2.4 → 0.43 MiB/frame; Metal grabs within the run-to-run noise floor
  313265d  Studio code atlas: geometry code views, live filter, 3D size lens, lanes as terminals
  9c4e0cc  Studio code atlas: performance round — retained uploads, worker labels, exact search, no forks
  7235d7e  Studio code atlas: stall fix, GPU working set, lens hard switch, filter masks, parallel index
  56a6da5  platform: per-pass GPU counter timing on Metal; retained publications replace in place
  72e2443  platform: present-path trace (1 Hz cause histograms), bounded drawable wait and retirement on macOS
  1c5d583  platform: bounded retained maintenance on empty paint beats; republish actual backend debt
  edfed73  platform: retained residency high/low water and hysteresis; no distance eviction without pressure
  acab6a0  platform: critical upload class serves present-blocking items first; identical immediate re-records upload nothing
  906c774  platform: uniform_range on DrawVars and patch_retained_uniforms on retained draw lists
  c29031c  platform/draw/widgets: heap-keyed script resources and RecordingBuffer draw items — the files today's commits depend on
  cd0964f  platform headless: homogeneous near-plane clipping before the perspective divide
  61d424d  platform: release retained bindings of released textures so pool evictions complete
  a00287a  platform: texture-tile cache support — per-item instance ranges, painted pass receipts, display-dpi pass uniform, retained render targets, present gate on the drawable pool
  74b63be  platform: retained upload floor reverted, unconfirmed presents counted
  c03fcc5  platform: tile-cache round 3 support — O(1) demand on re-recorded lists, evictions counter, allocated_size, lost-button release, Vec2d::round
  e515d75  platform: shared instance publications — the DL-0/DL-1 contract, additive beside the retained path
  142a337  platform: shared instance publications — close the seven review items (DL-1b)
  6811a19  platform: Debug for SharedInstances, WeakSharedInstances and PublishReceipt
  c061e47  platform: Metal draws are resident by construction — the hole path and its gates are gone (DL-2)
  0bdbb29  platform: drop the unreachable InstancesNotResident present cause
  de3c868  platform: `drawlist` trace names the holder of a stale draw-list id; the per-frame upload line moves to `gpu.upload`
  721c3d8  platform: publication backends — Metal per-publication backings, lease-keyed uniform ring, receipts on every backend, Vulkan draws attached items (DL-3)
  393de54  platform: integrated deletion — the draw-list system is generic again (DL-5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:23 +02:00

841 lines
33 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::script_mod(vm);
crate::script_mod(vm);
vm.bx.captured_errors = Some(Vec::new());
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);
}