use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::widget::Widget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_text::WrapMode;
use teksilo_text::text_document::{MoveMode, TextDocument};
use super::config::CodeConfig;
use super::policy::{CODE_EDITOR_PRESET, CODE_READ_ONLY_PRESET};
use super::state::{CodeEditorState, SharedState};
use super::{body_for, construct};
fn editor_state(text: &str) -> SharedState {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
construct(
doc,
CODE_EDITOR_PRESET,
CodeConfig::default(),
WrapMode::None,
)
}
fn viewer_state(text: &str) -> SharedState {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
construct(
doc,
CODE_READ_ONLY_PRESET,
CodeConfig::default(),
WrapMode::None,
)
}
#[test]
fn a_viewer_starts_with_its_caret_already_hidden() {
let st = viewer_state("fn main() {}");
assert!(!st.borrow().caret_visible.get());
}
#[test]
fn an_editor_starts_with_its_caret_shown() {
let st = editor_state("fn main() {}");
assert!(st.borrow().caret_visible.get());
}
#[test]
fn a_fresh_editor_has_exactly_one_caret() {
let st = editor_state("hello");
let s = st.borrow();
assert_eq!(s.all_carets().count(), 1);
assert_eq!(s.caret_count.get(), 1);
}
#[test]
fn the_code_editor_does_not_wrap_by_default() {
let st = editor_state("a very long line of source code that would wrap in prose");
assert_eq!(st.borrow().engine.wrap_mode(), WrapMode::None);
}
#[test]
fn syncing_a_new_viewport_requests_a_relayout() {
let st = editor_state("x");
{
let mut s = st.borrow_mut();
s.needs_full_layout = false;
assert!(s.sync_viewport(Rect::new(0.0, 0.0, 400.0, 300.0)));
assert_eq!(s.viewport_width, 400.0);
assert_eq!(s.viewport_height, 300.0);
assert!(
s.needs_full_layout,
"a resized viewport must force a relayout, or the text stays laid \
out at the old width"
);
}
}
#[test]
fn re_syncing_the_same_viewport_is_a_no_op() {
let st = editor_state("x");
let mut s = st.borrow_mut();
s.sync_viewport(Rect::new(0.0, 0.0, 400.0, 300.0));
s.needs_full_layout = false;
assert!(!s.sync_viewport(Rect::new(0.0, 0.0, 400.0, 300.0)));
assert!(
!s.needs_full_layout,
"an unchanged viewport must not relayout — paint re-syncs every frame"
);
}
#[test]
fn syncing_tracks_the_origin_even_when_the_size_is_unchanged() {
let st = editor_state("x");
let mut s = st.borrow_mut();
s.sync_viewport(Rect::new(0.0, 0.0, 400.0, 300.0));
s.sync_viewport(Rect::new(17.0, 42.0, 400.0, 300.0));
assert_eq!(s.viewport_origin.x, 17.0);
assert_eq!(s.viewport_origin.y, 42.0);
}
#[test]
fn clearing_extra_carets_reports_whether_any_existed() {
let st = editor_state("one\ntwo\nthree");
let mut s = st.borrow_mut();
assert!(
!s.clear_extra_carets(),
"clearing with only the primary caret must report no change, so Escape \
can fall through to whatever else wants it"
);
let extra = s.document.cursor();
s.extra_carets.push(extra);
assert!(s.clear_extra_carets());
assert_eq!(s.all_carets().count(), 1);
}
#[test]
fn a_paint_only_highlight_change_recolours_without_relayout() {
let st = editor_state("fn main() {}");
{
let mut s = st.borrow_mut();
s.needs_full_layout = false;
s.event_queue.lock().unwrap().push_back(
teksilo_text::text_document::DocumentEvent::HighlightPaintChanged {
position: 0,
length: 2,
},
);
}
let (had, single) = st.borrow_mut().drain_events();
let s = st.borrow();
assert!(had);
assert_eq!(single, None);
assert!(
s.pending_recolor,
"a highlight repaint must request a recolour"
);
assert!(
!s.needs_full_layout,
"a colour change must not reshape — it cannot alter a glyph advance"
);
}
#[test]
fn a_single_block_edit_yields_a_relayout_hint() {
let st = editor_state("a\nb");
{
let mut s = st.borrow_mut();
s.needs_full_layout = false;
s.event_queue.lock().unwrap().push_back(
teksilo_text::text_document::DocumentEvent::ContentsChanged {
position: 1,
chars_removed: 0,
chars_added: 1,
blocks_affected: 1,
},
);
}
let (_, single) = st.borrow_mut().drain_events();
assert_eq!(single, Some(1));
assert!(!st.borrow().needs_full_layout);
}
#[test]
fn a_multi_block_edit_forces_a_full_relayout() {
let st = editor_state("a\nb\nc");
{
let mut s = st.borrow_mut();
s.needs_full_layout = false;
s.event_queue.lock().unwrap().push_back(
teksilo_text::text_document::DocumentEvent::ContentsChanged {
position: 0,
chars_removed: 0,
chars_added: 4,
blocks_affected: 3,
},
);
}
let (_, single) = st.borrow_mut().drain_events();
assert_eq!(single, None);
assert!(st.borrow().needs_full_layout);
}
#[test]
fn an_edit_invalidates_the_accessibility_snapshot() {
let st = editor_state("hello");
{
let s = st.borrow();
*s.accessibility_flow_snapshot.borrow_mut() = Some(s.document.snapshot_flow());
assert!(s.accessibility_flow_snapshot.borrow().is_some());
}
{
let mut s = st.borrow_mut();
s.event_queue.lock().unwrap().push_back(
teksilo_text::text_document::DocumentEvent::ContentsChanged {
position: 0,
chars_removed: 0,
chars_added: 1,
blocks_affected: 1,
},
);
s.drain_events();
}
assert!(
st.borrow().accessibility_flow_snapshot.borrow().is_none(),
"a stale AT snapshot would report the previous edit's tree"
);
}
#[test]
fn draining_an_empty_queue_reports_no_work() {
let st = editor_state("x");
let (had, single) = st.borrow_mut().drain_events();
assert!(!had);
assert_eq!(single, None);
}
#[test]
fn undo_state_is_debounced_rather_than_published_immediately() {
let st = editor_state("x");
{
let mut s = st.borrow_mut();
s.event_queue.lock().unwrap().push_back(
teksilo_text::text_document::DocumentEvent::UndoRedoChanged {
can_undo: true,
can_redo: false,
},
);
s.drain_events();
}
let s = st.borrow();
assert_eq!(
s.pending_undo_redo,
Some((true, false)),
"undo state must be parked for the debounce window, not fanned out to \
every toolbar observer per keystroke"
);
}
#[test]
fn the_body_fills_its_proposal_by_default() {
let mut tree = WidgetTree::new();
let st = editor_state("fn main() {}");
let id = tree.add(body_for(&st, None, None));
tree.layout(SizeProposal::exact(400.0, 300.0));
let b = tree.bounds(id);
assert!((b.width - 400.0).abs() < 0.01);
assert!((b.height - 300.0).abs() < 0.01);
}
#[test]
fn laying_out_the_body_adopts_the_viewport() {
let mut tree = WidgetTree::new();
let st = editor_state("fn main() {}");
tree.add(body_for(&st, None, None));
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(st.borrow().viewport_width, 400.0);
assert_eq!(
st.borrow().viewport_height,
300.0,
"layout must adopt the viewport — paint runs after, and would otherwise \
be the first to learn the width"
);
}
fn force_layout(st: &SharedState, width: f32, height: f32) {
let mut s = st.borrow_mut();
s.engine.set_viewport(width, height);
let flow = s.document.snapshot_flow();
s.engine.layout_full(&flow);
}
#[test]
fn min_lines_floors_an_empty_intrinsic_body() {
let mut tree = WidgetTree::new();
let st = editor_state("");
let line_h = st.borrow().engine.default_line_height();
assert!(line_h > 0.0, "the embedded font must report a line height");
let id = tree.add(body_for(&st, Some(3), None));
tree.layout(SizeProposal::with_width(400.0));
let h = tree.bounds(id).height;
assert!(
(h - 3.0 * line_h).abs() < 1.0,
"an empty composer must still stand 3 lines tall, expected ~{}, got {h}",
3.0 * line_h
);
}
#[test]
fn max_lines_caps_an_intrinsic_body() {
let mut tree = WidgetTree::new();
let st = editor_state("a\nb\nc\nd\ne\nf\ng\nh\ni\nj");
force_layout(&st, 400.0, 600.0);
let (line_h, content_h) = {
let s = st.borrow();
(s.engine.default_line_height(), s.engine.content_height())
};
assert!(
content_h > 2.0 * line_h,
"the fixture must genuinely overflow the cap ({content_h} vs {}), else \
this proves nothing",
2.0 * line_h
);
let id = tree.add(body_for(&st, None, Some(2)));
tree.layout(SizeProposal::with_width(400.0));
let h = tree.bounds(id).height;
assert!(
h <= 2.0 * line_h + 1.0,
"10 lines under max_lines(2) must cap at ~{}, got {h}",
2.0 * line_h
);
}
#[test]
fn min_and_max_lines_clamp_growth_within_the_window() {
let mut tree = WidgetTree::new();
let st = editor_state("one\ntwo\nthree\nfour\nfive\nsix");
force_layout(&st, 400.0, 600.0);
let line_h = st.borrow().engine.default_line_height();
let id = tree.add(body_for(&st, Some(1), Some(4)));
tree.layout(SizeProposal::with_width(400.0));
let h = tree.bounds(id).height;
assert!(
h >= line_h - 0.5 && h <= 4.0 * line_h + 0.5,
"intrinsic height must land in [1, 4] × {line_h}, got {h}"
);
}
#[test]
fn the_body_clips_its_content() {
let st = editor_state("x");
assert!(body_for(&st, None, None).clips_children());
}
#[test]
fn an_editable_body_reports_a_multiline_text_input() {
use teksilo_core::accesskit::{Action, Role};
let st = editor_state("fn main() {}");
let body = body_for(&st, None, None);
let mut b = teksilo_core::accessibility::AccessNodeBuilder::for_widget(
teksilo_core::widget_id::WidgetId::default(),
);
body.accessibility(&mut b);
assert_eq!(b.role(), Role::MultilineTextInput);
assert!(b.actions().contains(&Action::SetValue));
assert!(b.actions().contains(&Action::SetTextSelection));
}
#[test]
fn a_viewer_reports_a_text_range_capable_role_and_no_set_value() {
use teksilo_core::accesskit::{Action, Role};
let st = viewer_state("2026-07-17 INFO ready");
let body = body_for(&st, None, None);
let mut b = teksilo_core::accessibility::AccessNodeBuilder::for_widget(
teksilo_core::widget_id::WidgetId::default(),
);
body.accessibility(&mut b);
assert_eq!(b.role(), Role::Document);
assert!(
!b.actions().contains(&Action::SetValue),
"a read-only surface must not advertise SetValue"
);
assert!(
b.actions().contains(&Action::SetTextSelection),
"a viewer must still support selection — that is what makes its text \
readable and copyable through AT"
);
}
fn a11y_children(
st: &SharedState,
) -> Vec<(
teksilo_core::accesskit::NodeId,
teksilo_core::accesskit::Node,
)> {
use teksilo_core::widget_id::WidgetId;
let body = body_for(st, None, None);
let mut b = teksilo_core::accessibility::AccessNodeBuilder::for_widget(WidgetId::default());
body.accessibility(&mut b);
let (_id, _node, children) = b.build(WidgetId::default());
children
}
fn roles(
children: &[(
teksilo_core::accesskit::NodeId,
teksilo_core::accesskit::Node,
)],
role: teksilo_core::accesskit::Role,
) -> Vec<&teksilo_core::accesskit::Node> {
children
.iter()
.filter(|(_, n)| n.role() == role)
.map(|(_, n)| n)
.collect()
}
fn resolved_caret(update: &teksilo_core::accesskit::TreeUpdate, st: &SharedState) -> Option<usize> {
let sel = update.nodes.iter().find_map(|(_, n)| n.text_selection())?;
let borrow = st.borrow();
let map = borrow.synthetic_to_element.borrow();
map.get(&sel.focus.node)
.map(|er| er.absolute_start + sel.focus.character_index)
}
#[test]
fn a_caret_move_alone_rewalks_the_accessibility_selection() {
let st = editor_state("hello world");
let mut tree = WidgetTree::new();
let _ = tree.add(body_for(&st, None, None));
tree.layout(SizeProposal::exact(400.0, 300.0));
let base = tree.sync_accessibility();
assert_eq!(resolved_caret(&base, &st), Some(0), "baseline caret at 0");
st.borrow().cursor.set_position(5, MoveMode::MoveAnchor);
super::sync_cursor_signals(&st);
tree.layout(SizeProposal::exact(400.0, 300.0));
let after = tree.sync_accessibility();
assert_eq!(
resolved_caret(&after, &st),
Some(5),
"the AT selection must follow a caret-only move (0 = stale cached tree)",
);
}
#[test]
fn the_walk_emits_a_paragraph_and_runs_per_line() {
use teksilo_core::accesskit::Role;
let st = editor_state("alpha\nbeta\ngamma");
let children = a11y_children(&st);
assert_eq!(
roles(&children, Role::Paragraph).len(),
3,
"one paragraph a line"
);
assert!(
roles(&children, Role::TextRun).len() >= 3,
"at least one run a line"
);
}
#[test]
fn each_line_is_numbered_in_the_set() {
use teksilo_core::accesskit::Role;
let st = editor_state("one\ntwo\nthree");
let children = a11y_children(&st);
let paras = roles(&children, Role::Paragraph);
assert_eq!(paras.len(), 3);
for (i, p) in paras.iter().enumerate() {
assert_eq!(p.position_in_set(), Some(i + 1), "1-based line number");
assert_eq!(p.size_of_set(), Some(3), "of the total line count");
}
}
#[test]
fn each_line_ends_with_a_newline() {
use teksilo_core::accesskit::Role;
let st = editor_state("a\nb\nc");
let children = a11y_children(&st);
let runs = roles(&children, Role::TextRun);
assert_eq!(runs.len(), 3, "single-char lines: one run each");
for run in runs {
assert!(
run.value().is_some_and(|v| v.ends_with('\n')),
"the line's last run must end with the newline, got {:?}",
run.value()
);
}
}
#[test]
fn a_long_line_is_split_into_linked_runs() {
use teksilo_core::accesskit::Role;
let long: String = "x".repeat(600);
let st = editor_state(&long);
let children = a11y_children(&st);
let runs = roles(&children, Role::TextRun);
assert!(
runs.len() >= 3,
"600 chars must split into >=3 runs, got {}",
runs.len()
);
assert!(runs[0].next_on_line().is_some(), "first run links forward");
assert!(
runs.last().unwrap().previous_on_line().is_some(),
"last run links back"
);
for run in &runs {
assert!(
run.character_lengths().len() <= 256,
"a chunk (plus its optional newline) stays within the cap"
);
}
}
#[test]
fn the_selection_map_locates_runs_in_the_document() {
let st = editor_state("hello\nworld");
let _ = a11y_children(&st); let s = st.borrow();
let map = s.synthetic_to_element.borrow();
let world = map.values().find(|r| r.text == "world");
assert!(world.is_some(), "the 'world' run must be mapped");
assert_eq!(
world.unwrap().absolute_start,
6,
"at the block's document start"
);
}
#[test]
fn appends_are_not_announced_unless_asked_for() {
let st = viewer_state("ready");
assert!(
!st.borrow().announce_appends,
"a live region must be opt-in — an unasked-for one is a denial of \
service against the screen reader"
);
}
#[test]
fn construction_carries_the_injected_config() {
let doc = TextDocument::new();
let cfg = CodeConfig {
line_comment: Some("--".to_string()),
..CodeConfig::default()
};
let st = construct(doc, CODE_EDITOR_PRESET, cfg, WrapMode::None);
assert_eq!(st.borrow().config.line_comment.as_deref(), Some("--"));
}
fn _assert_state_is_sized(_: &CodeEditorState) {}
use super::frame_loop;
#[test]
fn an_idle_unfocused_editor_stops_asking_for_frames() {
let st = editor_state("fn main() {}");
force_layout(&st, 400.0, 300.0);
let more = frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert!(
!more,
"an idle editor must let the frame loop go quiet — Teksilo is \
draw-when-needed and this is what makes it so"
);
}
#[test]
fn the_tick_flushes_buffered_typing_into_the_document() {
let st = editor_state("");
force_layout(&st, 400.0, 300.0);
st.borrow_mut().pending_chars.push_str("let x = 1;");
frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert_eq!(st.borrow().document.to_plain_text().unwrap(), "let x = 1;");
assert!(
st.borrow().pending_chars.is_empty(),
"the buffer must be drained, or the next tick types it again"
);
}
#[test]
fn a_burst_of_typing_becomes_a_single_document_edit() {
let st = editor_state("");
force_layout(&st, 400.0, 300.0);
for c in "hello".chars() {
st.borrow_mut().pending_chars.push(c);
}
frame_loop::tick(&mut st.borrow_mut(), 0.016);
let queued = st.borrow().event_queue.lock().unwrap().len();
assert!(
queued <= 1,
"a 5-character burst must produce at most one document event, got \
{queued} — per-keystroke relayout is what makes a big file unusable"
);
}
#[test]
fn drag_auto_scroll_advances_over_time_and_keeps_the_loop_alive() {
let st = editor_state("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np");
force_layout(&st, 400.0, 40.0);
{
let mut s = st.borrow_mut();
s.viewport_width = 400.0;
s.viewport_height = 40.0;
s.drag_state = super::state::DragState::Selecting {
auto_scroll_v_per_s: 600.0,
};
}
let more = frame_loop::tick(&mut st.borrow_mut(), 0.1);
assert!(
more,
"an active auto-scroll must keep the frame loop pumping"
);
assert!(
st.borrow().scroll_y.get() > 0.0,
"velocity must integrate into an actual scroll"
);
}
#[test]
fn a_held_drag_with_no_velocity_lets_the_loop_idle() {
let st = editor_state("hello");
force_layout(&st, 400.0, 300.0);
st.borrow_mut().drag_state = super::state::DragState::Selecting {
auto_scroll_v_per_s: 0.0,
};
assert!(
!frame_loop::tick(&mut st.borrow_mut(), 0.016),
"holding the button still must not pump frames"
);
}
#[test]
fn the_tick_clamps_a_scroll_offset_past_the_end() {
let st = editor_state("one line");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.viewport_width = 400.0;
s.viewport_height = 300.0;
s.scroll_y.set(5000.0);
}
frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert_eq!(
st.borrow().scroll_y.get(),
0.0,
"a one-line document cannot scroll — the offset must be clamped back"
);
}
#[test]
fn typing_with_several_carets_inserts_at_each_one() {
let st = editor_state("ab");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.cursor.set_position(0, MoveMode::MoveAnchor);
let extra = s.document.cursor();
extra.set_position(1, MoveMode::MoveAnchor);
s.extra_carets.push(extra);
s.pending_chars.push('X');
}
frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert_eq!(
st.borrow().document.to_plain_text().unwrap(),
"XaXb",
"each caret must get the character — an ascending walk would shift the \
later carets and misplace them"
);
}
#[test]
fn multi_caret_typing_undoes_as_one_step() {
let st = editor_state("ab");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.cursor.set_position(0, MoveMode::MoveAnchor);
let extra = s.document.cursor();
extra.set_position(1, MoveMode::MoveAnchor);
s.extra_carets.push(extra);
s.pending_chars.push('X');
}
frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert_eq!(st.borrow().document.to_plain_text().unwrap(), "XaXb");
let _ = st.borrow().document.undo();
assert_eq!(
st.borrow().document.to_plain_text().unwrap(),
"ab",
"one undo must revert the whole multi-caret insert, not one caret of it"
);
}
#[test]
fn consecutive_typing_ticks_coalesce_into_one_undo_step() {
let st = editor_state("");
force_layout(&st, 400.0, 300.0);
st.borrow_mut().pending_chars.push('a');
frame_loop::tick(&mut st.borrow_mut(), 0.016);
st.borrow_mut().pending_chars.push('b');
frame_loop::tick(&mut st.borrow_mut(), 0.016);
assert_eq!(st.borrow().document.to_plain_text().unwrap(), "ab");
let _ = st.borrow().document.undo();
assert_eq!(
st.borrow().document.to_plain_text().unwrap(),
"",
"typing coalesces: one undo removes the run, which is what a user means \
by undoing their typing"
);
}
#[test]
fn carets_that_collide_are_merged() {
let st = editor_state("hello world");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.cursor.set_position(3, MoveMode::MoveAnchor);
let extra = s.document.cursor();
extra.set_position(5, MoveMode::MoveAnchor);
s.extra_carets.push(extra);
}
{
let s = st.borrow_mut();
s.cursor.set_position(0, MoveMode::MoveAnchor);
s.extra_carets[0].set_position(0, MoveMode::MoveAnchor);
}
st.borrow_mut().pending_chars.push('Z');
frame_loop::tick(&mut st.borrow_mut(), 0.016);
let text = st.borrow().document.to_plain_text().unwrap();
assert_eq!(
text, "Zhello world",
"two carets at one offset must insert one character, not two — got {text:?}"
);
}
#[test]
fn alt_click_adds_then_removes_a_caret() {
let st = editor_state("hello world");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.cursor.set_position(0, MoveMode::MoveAnchor);
super::mouse::add_caret_at(&mut s, 6);
assert_eq!(s.extra_carets.len(), 1);
super::mouse::add_caret_at(&mut s, 6);
assert_eq!(
s.extra_carets.len(),
0,
"alt-clicking an existing caret must remove it"
);
}
}
#[test]
fn alt_click_on_the_primary_caret_is_ignored() {
let st = editor_state("hello");
let mut s = st.borrow_mut();
s.cursor.set_position(2, MoveMode::MoveAnchor);
super::mouse::add_caret_at(&mut s, 2);
assert!(
s.extra_carets.is_empty(),
"the primary caret must survive an alt-click on itself"
);
assert_eq!(s.all_carets().count(), 1);
}
#[test]
fn a_cancelled_composition_leaves_the_document_clean() {
let st = editor_state("ab");
force_layout(&st, 400.0, 300.0);
{
let mut s = st.borrow_mut();
s.cursor.set_position(1, MoveMode::MoveAnchor);
let start = s.cursor.position();
let _ = s.cursor.insert_text("ん");
let end = s.cursor.position();
s.ime_preedit = Some("ん".to_string());
s.ime_preedit_range = Some(start..end);
}
assert_eq!(st.borrow().document.to_plain_text().unwrap(), "aんb");
super::keyboard::clear_ime_preedit(&st);
assert_eq!(
st.borrow().document.to_plain_text().unwrap(),
"ab",
"cancelling must remove the tentative text — leaving it would make an \
abandoned composition permanent"
);
assert!(st.borrow().ime_preedit.is_none());
assert!(st.borrow().ime_preedit_range.is_none());
}
#[test]
fn a_stale_preedit_range_cannot_delete_past_the_end() {
let st = editor_state("ab");
{
let mut s = st.borrow_mut();
s.ime_preedit_range = Some(100..200);
s.ime_preedit = Some("x".to_string());
}
super::keyboard::clear_ime_preedit(&st);
assert_eq!(
st.borrow().document.to_plain_text().unwrap(),
"ab",
"a stale range must clamp to the live length rather than corrupt"
);
}
#[test]
fn home_toggles_between_the_indent_and_column_zero() {
let st = editor_state(" indented");
force_layout(&st, 400.0, 300.0);
st.borrow().cursor.set_position(12, MoveMode::MoveAnchor);
super::keyboard::smart_home_for_test(&st);
assert_eq!(
st.borrow().cursor.position(),
4,
"Home must land on the content, not the margin — that is where the \
caret is wanted nine times out of ten"
);
super::keyboard::smart_home_for_test(&st);
assert_eq!(
st.borrow().cursor.position(),
0,
"a second Home must reach column 0, or re-indenting is impossible"
);
super::keyboard::smart_home_for_test(&st);
assert_eq!(st.borrow().cursor.position(), 4);
}
#[test]
fn home_on_an_unindented_line_goes_to_column_zero() {
let st = editor_state("flush");
force_layout(&st, 400.0, 300.0);
st.borrow().cursor.set_position(3, MoveMode::MoveAnchor);
super::keyboard::smart_home_for_test(&st);
assert_eq!(st.borrow().cursor.position(), 0);
super::keyboard::smart_home_for_test(&st);
assert_eq!(st.borrow().cursor.position(), 0);
}
#[test]
fn home_toggles_each_caret_about_its_own_line() {
let st = editor_state(" alpha\nbeta");
force_layout(&st, 400.0, 300.0);
{
let s = st.borrow_mut();
s.cursor.set_position(9, MoveMode::MoveAnchor);
}
{
let mut s = st.borrow_mut();
let extra = s.document.cursor();
extra.set_position(14, MoveMode::MoveAnchor);
s.extra_carets.push(extra);
}
super::keyboard::smart_home_for_test(&st);
let s = st.borrow();
assert_eq!(s.cursor.position(), 4, "indented line → its indent");
assert_eq!(
s.extra_carets[0].position(),
10,
"unindented line → its column 0, independently of the primary"
);
}
use super::gutter::CodeGutter;
#[test]
fn the_line_count_tracks_the_document_via_the_event() {
let st = editor_state("a\nb\nc");
assert_eq!(
st.borrow().line_count.get(),
3,
"seeded once at construction"
);
{
let mut s = st.borrow_mut();
s.event_queue
.lock()
.unwrap()
.push_back(teksilo_text::text_document::DocumentEvent::BlockCountChanged(9));
s.drain_events();
}
assert_eq!(
st.borrow().line_count.get(),
9,
"BlockCountChanged carries the count — that is the only affordable way \
to learn it"
);
}
#[test]
fn a_changed_line_count_forces_a_relayout() {
let st = editor_state("a\nb");
{
let mut s = st.borrow_mut();
s.needs_full_layout = false;
s.event_queue
.lock()
.unwrap()
.push_back(teksilo_text::text_document::DocumentEvent::BlockCountChanged(5));
s.drain_events();
}
assert!(st.borrow().needs_full_layout);
}
#[test]
fn the_gutter_is_hidden_from_assistive_technology() {
let st = editor_state("a\nb\nc");
let g = CodeGutter::new(&st);
let mut b = teksilo_core::accessibility::AccessNodeBuilder::new();
g.accessibility(&mut b);
assert!(
b.is_hidden(),
"the gutter must be hidden — its numbers belong on the paragraphs, not \
as thirty nodes in the reader's path"
);
}
#[test]
fn the_gutter_widens_with_the_total_line_count_not_the_visible_one() {
let mut tree = WidgetTree::new();
let small = editor_state("a\nb\nc");
let id_small = tree.add(CodeGutter::new(&small));
tree.layout(SizeProposal::with_height(300.0));
let w_small = tree.bounds(id_small).width;
let mut tree2 = WidgetTree::new();
let big = editor_state("x");
big.borrow().line_count.set(100_000);
let id_big = tree2.add(CodeGutter::new(&big));
tree2.layout(SizeProposal::with_height(300.0));
let w_big = tree2.bounds(id_big).width;
assert!(
w_big > w_small,
"a 100k-line document needs a wider gutter than a 3-line one ({w_big} \
vs {w_small})"
);
}
#[test]
fn the_gutter_width_does_not_change_when_scrolled() {
let mut tree = WidgetTree::new();
let st = editor_state("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl");
let id = tree.add(CodeGutter::new(&st));
tree.layout(SizeProposal::with_height(40.0));
let before = tree.bounds(id).width;
st.borrow().scroll_y.set(500.0);
tree.layout(SizeProposal::with_height(40.0));
let after = tree.bounds(id).width;
assert_eq!(
before, after,
"scrolling must never resize the gutter — the code would shift sideways"
);
}
#[test]
fn the_gutter_paints_nothing_before_the_first_layout() {
let st = editor_state("a\nb\nc");
assert!(!st.borrow().engine.has_full_layout());
let mut tree = WidgetTree::new();
tree.add(CodeGutter::new(&st));
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
}
use super::config::{BracketPair, COMMON_BRACKETS, IndentStyle};
use super::semantics::{self, MoveDir};
use super::widget::{CodeEditor, PlainTextEditor};
fn editor_cfg(text: &str, config: CodeConfig) -> SharedState {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
construct(doc, CODE_EDITOR_PRESET, config, WrapMode::None)
}
fn text_of(st: &SharedState) -> String {
st.borrow().document.to_plain_text().unwrap()
}
fn set_caret(st: &SharedState, pos: usize) {
st.borrow().cursor.set_position(pos, MoveMode::MoveAnchor);
}
fn set_selection(st: &SharedState, a: usize, b: usize) {
let s = st.borrow();
s.cursor.set_position(a, MoveMode::MoveAnchor);
s.cursor.set_position(b, MoveMode::KeepAnchor);
}
fn caret(st: &SharedState) -> usize {
st.borrow().cursor.position()
}
fn add_extra_selection(st: &SharedState, a: usize, b: usize) {
let mut s = st.borrow_mut();
let c = s.document.cursor();
c.set_position(a, MoveMode::MoveAnchor);
c.set_position(b, MoveMode::KeepAnchor);
s.extra_carets.push(c);
}
fn run(st: &SharedState, f: impl FnOnce(&mut CodeEditorState)) {
let mut s = st.borrow_mut();
f(&mut s);
}
fn bracket_config() -> CodeConfig {
CodeConfig {
brackets: COMMON_BRACKETS.to_vec(),
auto_close_brackets: true,
match_brackets: true,
..CodeConfig::default()
}
}
#[test]
fn enter_carries_leading_whitespace() {
let st = editor_cfg(" foo", CodeConfig::default());
set_caret(&st, 7); run(&st, semantics::newline);
assert_eq!(text_of(&st), " foo\n ");
assert_eq!(caret(&st), 12, "caret sits after the carried indent");
}
#[test]
fn enter_without_auto_indent_is_a_plain_break() {
let st = editor_cfg(
" foo",
CodeConfig {
auto_indent: false,
..CodeConfig::default()
},
);
set_caret(&st, 7);
run(&st, semantics::newline);
assert_eq!(text_of(&st), " foo\n");
assert_eq!(caret(&st), 8);
}
#[test]
fn enter_between_brackets_opens_a_block() {
let st = editor_cfg("{}", bracket_config());
set_caret(&st, 1); run(&st, semantics::newline);
assert_eq!(text_of(&st), "{\n \n}");
assert_eq!(caret(&st), 6, "caret rests on the indented middle line");
}
#[test]
fn enter_between_brackets_respects_existing_indentation() {
let st = editor_cfg(" {}", bracket_config());
set_caret(&st, 5); run(&st, semantics::newline);
assert_eq!(text_of(&st), " {\n \n }");
}
#[test]
fn tab_with_no_selection_inserts_to_the_next_stop() {
let st = editor_cfg("", CodeConfig::default());
set_caret(&st, 0);
run(&st, semantics::indent_or_tab);
assert_eq!(text_of(&st), " ");
assert_eq!(caret(&st), 4);
}
#[test]
fn tab_from_a_ragged_column_reaches_the_stop() {
let st = editor_cfg("ab", CodeConfig::default());
set_caret(&st, 2); run(&st, semantics::indent_or_tab);
assert_eq!(text_of(&st), "ab ", "two spaces reach column 4");
assert_eq!(caret(&st), 4);
}
#[test]
fn tab_with_a_selection_indents_every_touched_line() {
let st = editor_cfg("a\nb", CodeConfig::default());
set_selection(&st, 0, 3); run(&st, semantics::indent_or_tab);
assert_eq!(text_of(&st), " a\n b");
let s = st.borrow();
assert_eq!(s.cursor.selection_start(), 0);
assert_eq!(s.cursor.selection_end(), 11);
}
#[test]
fn a_selection_ending_at_a_line_start_does_not_indent_that_line() {
let st = editor_cfg("a\nb\nc", CodeConfig::default());
set_selection(&st, 0, 4);
run(&st, semantics::indent_or_tab);
assert_eq!(text_of(&st), " a\n b\nc", "line c is untouched");
}
#[test]
fn shift_tab_dedents_a_level() {
let st = editor_cfg(" x", CodeConfig::default());
set_caret(&st, 9);
run(&st, semantics::dedent);
assert_eq!(text_of(&st), " x", "8 spaces → 4");
}
#[test]
fn dedent_never_eats_content() {
let st = editor_cfg(" x", CodeConfig::default());
set_caret(&st, 3);
run(&st, semantics::dedent);
assert_eq!(text_of(&st), "x", "two spaces removed, the x kept");
}
#[test]
fn dedent_of_an_unindented_line_is_a_no_op() {
let st = editor_cfg("x", CodeConfig::default());
set_caret(&st, 0);
run(&st, semantics::dedent);
assert_eq!(text_of(&st), "x");
}
#[test]
fn comment_then_uncomment_round_trips() {
let cfg = CodeConfig {
line_comment: Some("//".to_string()),
..CodeConfig::default()
};
let st = editor_cfg("foo", cfg);
set_caret(&st, 0);
run(&st, semantics::toggle_line_comment);
assert_eq!(text_of(&st), "// foo");
run(&st, semantics::toggle_line_comment);
assert_eq!(text_of(&st), "foo", "a second toggle restores the original");
}
#[test]
fn comment_without_a_configured_token_does_nothing() {
let st = editor_cfg("foo", CodeConfig::default());
set_caret(&st, 0);
run(&st, semantics::toggle_line_comment);
assert_eq!(text_of(&st), "foo");
}
#[test]
fn comment_aligns_at_the_shallowest_indent() {
let cfg = CodeConfig {
line_comment: Some("//".to_string()),
..CodeConfig::default()
};
let st = editor_cfg(" a\n b", cfg);
set_selection(&st, 0, 9);
run(&st, semantics::toggle_line_comment);
assert_eq!(text_of(&st), " // a\n // b");
}
#[test]
fn a_partly_commented_block_comments_the_remainder() {
let cfg = CodeConfig {
line_comment: Some("//".to_string()),
..CodeConfig::default()
};
let st = editor_cfg("// a\nb", cfg);
set_selection(&st, 0, 6);
run(&st, semantics::toggle_line_comment);
assert_eq!(text_of(&st), "// // a\n// b");
}
#[test]
fn duplicate_line_copies_below_and_follows_the_caret() {
let st = editor_cfg("foo", CodeConfig::default());
set_caret(&st, 1);
run(&st, semantics::duplicate);
assert_eq!(text_of(&st), "foo\nfoo");
assert_eq!(caret(&st), 5, "caret is on the copy at the same column");
}
#[test]
fn duplicate_selection_copies_after_and_reselects() {
let st = editor_cfg("abcdef", CodeConfig::default());
set_selection(&st, 1, 4); run(&st, semantics::duplicate);
assert_eq!(text_of(&st), "abcdbcdef");
let s = st.borrow();
assert_eq!(s.cursor.selection_start(), 4);
assert_eq!(s.cursor.selection_end(), 7, "the copy is selected");
}
#[test]
fn move_line_up_swaps_with_the_line_above() {
let st = editor_cfg("a\nb\nc", CodeConfig::default());
set_caret(&st, 4); run(&st, |s| semantics::move_lines(s, MoveDir::Up));
assert_eq!(text_of(&st), "a\nc\nb");
assert_eq!(caret(&st), 2, "caret still on the moved line");
}
#[test]
fn move_line_down_swaps_with_the_line_below() {
let st = editor_cfg("a\nb\nc", CodeConfig::default());
set_caret(&st, 0); run(&st, |s| semantics::move_lines(s, MoveDir::Down));
assert_eq!(text_of(&st), "b\na\nc");
assert_eq!(caret(&st), 2);
}
#[test]
fn move_line_up_at_the_top_is_a_no_op() {
let st = editor_cfg("a\nb", CodeConfig::default());
set_caret(&st, 0);
run(&st, |s| semantics::move_lines(s, MoveDir::Up));
assert_eq!(text_of(&st), "a\nb");
}
#[test]
fn move_line_down_at_the_bottom_is_a_no_op() {
let st = editor_cfg("a\nb", CodeConfig::default());
set_caret(&st, 2); run(&st, |s| semantics::move_lines(s, MoveDir::Down));
assert_eq!(text_of(&st), "a\nb");
}
#[test]
fn move_up_carries_a_multi_line_selection() {
let st = editor_cfg("a\nb\nc\nd", CodeConfig::default());
set_selection(&st, 2, 5);
run(&st, |s| semantics::move_lines(s, MoveDir::Up));
assert_eq!(text_of(&st), "b\nc\na\nd");
let s = st.borrow();
assert_eq!(s.cursor.selection_start(), 0);
assert_eq!(
s.cursor.selection_end(),
3,
"the moved block stays selected"
);
}
#[test]
fn auto_close_inserts_the_pair_and_sits_between() {
let st = editor_cfg("", bracket_config());
set_caret(&st, 0);
run(&st, |s| semantics::type_bracket_char(s, '('));
assert_eq!(text_of(&st), "()");
assert_eq!(caret(&st), 1, "caret rests between the pair");
}
#[test]
fn auto_close_is_suppressed_before_a_word() {
let st = editor_cfg("foo", bracket_config());
set_caret(&st, 0);
run(&st, |s| semantics::type_bracket_char(s, '('));
assert_eq!(text_of(&st), "(foo", "no closer inserted before the word");
assert_eq!(caret(&st), 1);
}
#[test]
fn auto_close_surrounds_a_selection() {
let st = editor_cfg("abc", bracket_config());
set_selection(&st, 0, 3);
run(&st, |s| semantics::type_bracket_char(s, '('));
assert_eq!(text_of(&st), "(abc)");
let s = st.borrow();
assert_eq!(s.cursor.selection_start(), 1);
assert_eq!(
s.cursor.selection_end(),
4,
"the wrapped text stays selected"
);
}
#[test]
fn typing_a_closer_steps_over_the_auto_closed_one() {
let st = editor_cfg("()", bracket_config());
set_caret(&st, 1); run(&st, |s| semantics::type_bracket_char(s, ')'));
assert_eq!(text_of(&st), "()", "no second closer inserted");
assert_eq!(caret(&st), 2, "caret stepped past the closer");
}
#[test]
fn pair_backspace_deletes_both() {
let st = editor_cfg("()", bracket_config());
set_caret(&st, 1);
let consumed = run_bool(&st, semantics::try_pair_backspace);
assert!(consumed, "pair-backspace claims the keystroke");
assert_eq!(text_of(&st), "");
}
#[test]
fn pair_backspace_declines_when_not_between_a_pair() {
let st = editor_cfg("ab", bracket_config());
set_caret(&st, 1);
let consumed = run_bool(&st, semantics::try_pair_backspace);
assert!(!consumed, "not a pair → declines");
assert_eq!(text_of(&st), "ab", "and changes nothing");
}
fn run_bool(st: &SharedState, f: impl FnOnce(&mut CodeEditorState) -> bool) -> bool {
let mut s = st.borrow_mut();
f(&mut s)
}
#[test]
fn add_caret_below_lands_at_the_same_column() {
let st = editor_cfg("abc\ndef", CodeConfig::default());
set_caret(&st, 1); run(&st, semantics::add_caret_below);
let s = st.borrow();
assert_eq!(s.all_carets().count(), 2);
assert!(s.extra_carets.iter().any(|c| c.position() == 5));
}
#[test]
fn add_caret_above_clamps_to_a_shorter_line() {
let st = editor_cfg("ab\nwxyz", CodeConfig::default());
set_caret(&st, 7); run(&st, semantics::add_caret_above);
let s = st.borrow();
assert_eq!(s.all_carets().count(), 2);
assert!(s.extra_carets.iter().any(|c| c.position() == 2));
}
#[test]
fn add_caret_below_at_the_last_line_is_a_no_op() {
let st = editor_cfg("only", CodeConfig::default());
set_caret(&st, 2);
run(&st, semantics::add_caret_below);
assert_eq!(st.borrow().all_carets().count(), 1);
}
#[test]
fn multi_caret_indent_covers_each_carets_line_only() {
let st = editor_cfg("aa\nbb\ncc", CodeConfig::default());
set_selection(&st, 0, 1); add_extra_selection(&st, 6, 7); run(&st, semantics::indent_or_tab);
assert_eq!(
text_of(&st),
" aa\nbb\n cc",
"aa and cc indent; the untouched bb does not"
);
}
#[test]
fn multi_caret_auto_close_inserts_a_pair_at_each() {
let st = editor_cfg("a\nb", bracket_config());
set_caret(&st, 1); add_extra_selection(&st, 3, 3); run(&st, |s| semantics::type_bracket_char(s, '('));
assert_eq!(text_of(&st), "a()\nb()");
}
#[test]
fn bracket_match_finds_the_partner() {
let st = editor_cfg("(a)", bracket_config());
set_caret(&st, 1); let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, Some((0, 2)), "the '(' at 0 matches the ')' at 2");
}
#[test]
fn bracket_match_respects_nesting() {
let st = editor_cfg("(())", bracket_config());
set_caret(&st, 1); let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, Some((0, 3)), "the outer '(' matches the outer ')'");
}
#[test]
fn bracket_match_scans_backward_from_a_closer() {
let st = editor_cfg("(a)", bracket_config());
set_caret(&st, 3); let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, Some((2, 0)), "the ')' at 2 matches the '(' at 0");
}
#[test]
fn bracket_match_crosses_lines_backward() {
let st = editor_cfg("(\n)", bracket_config());
set_caret(&st, 3);
let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(
m,
Some((2, 0)),
"the ')' on line 2 matches the '(' on line 1"
);
}
#[test]
fn bracket_match_crosses_lines_forward() {
let st = editor_cfg("(\n)", bracket_config());
set_caret(&st, 1); let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, Some((0, 2)));
}
#[test]
fn bracket_match_is_none_when_unbalanced() {
let st = editor_cfg("(a", bracket_config());
set_caret(&st, 1);
let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, None);
}
#[test]
fn bracket_match_needs_configured_pairs() {
let st = editor_cfg("(a)", CodeConfig::default());
set_caret(&st, 1);
let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, None);
}
#[test]
fn a_single_caret_enter_is_one_undo_step() {
let st = editor_cfg(" foo", CodeConfig::default());
set_caret(&st, 7);
run(&st, semantics::newline);
assert_eq!(text_of(&st), " foo\n ");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(
text_of(&st),
" foo",
"one undo restores the pre-Enter text"
);
}
#[test]
fn a_bracket_expand_enter_is_one_undo_step() {
let st = editor_cfg("{}", bracket_config());
set_caret(&st, 1);
run(&st, semantics::newline);
assert_eq!(text_of(&st), "{\n \n}");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(text_of(&st), "{}", "one undo restores the collapsed pair");
}
#[test]
fn a_single_caret_duplicate_is_one_undo_step() {
let st = editor_cfg("foo", CodeConfig::default());
set_caret(&st, 1);
run(&st, semantics::duplicate);
assert_eq!(text_of(&st), "foo\nfoo");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(text_of(&st), "foo", "one undo removes the whole copy");
}
#[test]
fn a_single_caret_surround_is_one_undo_step() {
let st = editor_cfg("abc", bracket_config());
set_selection(&st, 0, 3);
run(&st, |s| semantics::type_bracket_char(s, '('));
assert_eq!(text_of(&st), "(abc)");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(text_of(&st), "abc", "one undo strips both delimiters");
}
#[test]
fn bracket_match_unmatched_opener_across_lines_terminates() {
let st = editor_cfg("(\na\nb", bracket_config());
set_caret(&st, 1); let m = semantics::current_bracket_match(&st.borrow());
assert_eq!(m, None, "no closer anywhere below → no match");
}
#[test]
fn a_multi_line_indent_is_one_undo_step() {
let st = editor_cfg("a\nb", CodeConfig::default());
set_selection(&st, 0, 3);
run(&st, semantics::indent_or_tab);
assert_eq!(text_of(&st), " a\n b");
let ok = st.borrow().document.undo();
assert!(ok.is_ok());
assert_eq!(text_of(&st), "a\nb", "one undo restores both lines");
}
#[test]
fn paste_splits_multiline_into_one_block_per_line() {
let st = editor_cfg("", CodeConfig::default());
super::clipboard::insert_multiline(&st.borrow().cursor, "a\nb\nc");
assert_eq!(text_of(&st), "a\nb\nc");
assert_eq!(
st.borrow().document.block_count(),
3,
"three lines → three blocks, no literal newline in any block"
);
}
#[test]
fn paste_of_one_line_stays_one_block() {
let st = editor_cfg("", CodeConfig::default());
super::clipboard::insert_multiline(&st.borrow().cursor, "hello");
assert_eq!(text_of(&st), "hello");
assert_eq!(st.borrow().document.block_count(), 1);
}
#[test]
fn a_multi_line_paste_is_one_undo_step() {
let st = editor_cfg("start", CodeConfig::default());
set_caret(&st, 5);
super::clipboard::insert_multiline(&st.borrow().cursor, "\nsecond\nthird");
assert_eq!(text_of(&st), "start\nsecond\nthird");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(text_of(&st), "start", "one undo removes the whole paste");
}
#[test]
fn paste_normalises_crlf() {
let st = editor_cfg("", CodeConfig::default());
let normalized = "a\r\nb".replace("\r\n", "\n").replace('\r', "\n");
super::clipboard::insert_multiline(&st.borrow().cursor, &normalized);
assert_eq!(text_of(&st), "a\nb");
assert_eq!(st.borrow().document.block_count(), 2);
}
#[test]
fn cut_line_removes_the_line_and_its_trailing_separator() {
let st = editor_cfg("a\nb\nc", CodeConfig::default());
set_caret(&st, 2); run(&st, super::clipboard::delete_line);
assert_eq!(text_of(&st), "a\nc");
}
#[test]
fn cut_the_last_line_takes_the_leading_separator() {
let st = editor_cfg("a\nb", CodeConfig::default());
set_caret(&st, 2); run(&st, super::clipboard::delete_line);
assert_eq!(text_of(&st), "a");
}
#[test]
fn cut_the_only_line_clears_it() {
let st = editor_cfg("solo", CodeConfig::default());
set_caret(&st, 2);
run(&st, super::clipboard::delete_line);
assert_eq!(text_of(&st), "");
}
#[test]
fn brackets_come_from_configuration_not_a_language() {
let cfg = CodeConfig {
brackets: vec![BracketPair::new('«', '»')],
auto_close_brackets: true,
..CodeConfig::default()
};
let st = editor_cfg("", cfg);
set_caret(&st, 0);
run(&st, |s| semantics::type_bracket_char(s, '«'));
assert_eq!(text_of(&st), "«»", "the app's own pair auto-closes");
}
fn doc(text: &str) -> TextDocument {
let d = TextDocument::new();
d.set_plain_text(text).unwrap();
d
}
#[test]
fn a_code_editor_defaults_current_line_on() {
let ed = CodeEditor::new(doc("fn main() {}"));
assert!(
ed.handle().state_handle().borrow().current_line_highlight,
"CodeEditor should default the current-line band on"
);
}
#[test]
fn a_plain_text_editor_defaults_to_prose() {
let ed = PlainTextEditor::new(doc("some notes"));
let st = ed.handle().state_handle();
let s = st.borrow();
assert!(!s.current_line_highlight, "no current-line band in prose");
assert_eq!(s.wrap_mode, WrapMode::Word, "prose wraps");
}
#[test]
fn builder_knobs_thread_into_the_config() {
let ed = CodeEditor::new(doc("x"))
.tab_width(2)
.line_comment("#")
.bracket_pairs(COMMON_BRACKETS.to_vec())
.auto_close_brackets(true)
.bracket_matching(true);
let st = ed.handle().state_handle();
let s = st.borrow();
assert_eq!(s.config.indent, IndentStyle::Spaces(2));
assert_eq!(s.config.line_comment.as_deref(), Some("#"));
assert_eq!(s.config.brackets, COMMON_BRACKETS.to_vec());
assert!(s.config.auto_close_brackets);
assert!(s.config.match_brackets);
}
#[test]
fn use_soft_tabs_flips_the_indent_kind() {
let hard = CodeEditor::new(doc("x")).tab_width(8).use_soft_tabs(false);
assert_eq!(
hard.handle().state_handle().borrow().config.indent,
IndentStyle::Tabs { width: 8 }
);
let soft = CodeEditor::new(doc("x")).use_soft_tabs(true);
assert!(matches!(
soft.handle().state_handle().borrow().config.indent,
IndentStyle::Spaces(_)
));
}
#[test]
fn a_read_only_code_editor_is_a_viewer() {
let ed = CodeEditor::read_only(doc("fn main() {}"));
let st = ed.handle().state_handle();
let s = st.borrow();
assert!(!s.caret_visible.get(), "a viewer starts with no caret");
assert!(s.policy.is_read_only());
}
#[test]
fn a_code_editor_mounts_lays_out_and_paints() {
let mut tree = WidgetTree::new();
let ed = CodeEditor::new(doc("fn main() {\n let x = (1 + 2);\n}"))
.bracket_pairs(COMMON_BRACKETS.to_vec())
.bracket_matching(true);
let id = tree.add(ed);
tree.layout(SizeProposal::exact(600.0, 400.0));
let b = tree.bounds(id);
assert_eq!(b.width, 600.0);
assert_eq!(b.height, 400.0);
let _ = tree.render();
}
#[test]
fn a_gutterless_code_editor_mounts() {
let mut tree = WidgetTree::new();
let ed = CodeEditor::new(doc("a\nb\nc")).gutter(false);
let id = tree.add(ed);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(tree.bounds(id).width, 400.0);
let _ = tree.render();
}
#[test]
fn a_plain_text_editor_mounts_and_fills() {
let mut tree = WidgetTree::new();
let ed = PlainTextEditor::new(doc("just some prose here"));
let id = tree.add(ed);
tree.layout(SizeProposal::exact(500.0, 200.0));
assert_eq!(tree.bounds(id).width, 500.0);
assert_eq!(tree.bounds(id).height, 200.0);
let _ = tree.render();
}
#[test]
fn min_lines_gives_intrinsic_height() {
let mut tree = WidgetTree::new();
let ed = PlainTextEditor::new(doc("one line"))
.min_lines(3)
.max_lines(6);
let id = tree.add(ed);
tree.layout(SizeProposal::with_width(400.0));
let h = tree.bounds(id).height;
assert!(h > 0.0, "intrinsic height must be positive, got {h}");
assert!(
h < 400.0,
"3–6 lines must be far shorter than a page, got {h}"
);
}
use super::completion::{self, CompletionContext, CompletionItem, CompletionKind, Trigger};
fn provider_of(labels: &[&str]) -> impl Fn(&CompletionContext) -> Vec<CompletionItem> + 'static {
let owned: Vec<String> = labels.iter().map(|s| s.to_string()).collect();
move |_cx| {
owned
.iter()
.map(|l| CompletionItem::new(l.clone()))
.collect()
}
}
fn completion_editor(text: &str, caret: usize, labels: &[&str]) -> SharedState {
let st = editor_cfg(text, CodeConfig::default());
st.borrow_mut().completion.provider = Some(std::rc::Rc::new(provider_of(labels)));
st.borrow().cursor.set_position(caret, MoveMode::MoveAnchor);
st
}
#[test]
fn word_prefix_extracts_the_identifier_before_the_caret() {
let st = editor_cfg("foo.bar", CodeConfig::default());
let (start, prefix) = semantics::word_prefix_before_caret(&st.borrow(), 7);
assert_eq!(prefix, "bar");
assert_eq!(start, 4, "starts after the dot");
}
#[test]
fn word_prefix_is_empty_after_a_separator() {
let st = editor_cfg("foo.", CodeConfig::default());
let (start, prefix) = semantics::word_prefix_before_caret(&st.borrow(), 4);
assert_eq!(prefix, "");
assert_eq!(start, 4);
}
#[test]
fn word_prefix_includes_underscores_and_digits() {
let st = editor_cfg("a1_b2", CodeConfig::default());
let (start, prefix) = semantics::word_prefix_before_caret(&st.borrow(), 5);
assert_eq!(prefix, "a1_b2");
assert_eq!(start, 0);
}
#[test]
fn accept_replaces_the_prefix_in_one_undo_step() {
let st = editor_cfg("list.pu", CodeConfig::default());
st.borrow().cursor.set_position(7, MoveMode::MoveAnchor);
{
let mut s = st.borrow_mut();
semantics::accept_completion(&mut s, 5, 7, "push");
}
assert_eq!(text_of(&st), "list.push");
assert!(st.borrow().document.undo().is_ok());
assert_eq!(
text_of(&st),
"list.pu",
"one undo restores the pre-accept text"
);
}
#[test]
fn completion_item_defaults_and_builders() {
let a = CompletionItem::new("foo");
assert_eq!(a.label, "foo");
assert_eq!(a.insert_text, "foo");
let b = CompletionItem::new("println")
.insert_text("println!()")
.detail("macro")
.kind(CompletionKind::Keyword);
assert_eq!(b.insert_text, "println!()");
assert_eq!(b.detail.as_deref(), Some("macro"));
assert_eq!(b.kind, CompletionKind::Keyword);
}
#[test]
fn typing_filters_the_candidate_list() {
let st = completion_editor("pre", 3, &["prefix", "press", "bar", "Preset"]);
completion::test_evaluate(&st, Trigger::Typed);
let labels = st.borrow().completion.test_labels();
assert_eq!(
labels,
vec!["prefix", "press", "Preset"],
"case-insensitive prefix match on 'pre'"
);
}
#[test]
fn a_prefix_matching_nothing_filters_to_empty() {
let st = completion_editor("xyz", 3, &["foo", "bar"]);
completion::test_evaluate(&st, Trigger::Typed);
assert!(st.borrow().completion.test_labels().is_empty());
}
#[test]
fn forced_trigger_with_no_prefix_shows_all() {
let st = completion_editor("", 0, &["alpha", "beta", "gamma"]);
completion::test_evaluate(&st, Trigger::Forced);
assert_eq!(st.borrow().completion.test_labels().len(), 3);
}
#[test]
fn no_provider_means_no_completion() {
let st = editor_cfg("pre", CodeConfig::default());
st.borrow().cursor.set_position(2, MoveMode::MoveAnchor);
assert!(!st.borrow().completion.has_provider());
assert!(st.borrow().completion.test_labels().is_empty());
}
#[test]
fn move_selection_wraps_around() {
let st = completion_editor("pre", 3, &["prefix", "press", "preset"]);
completion::test_evaluate(&st, Trigger::Typed);
assert_eq!(st.borrow().completion.selected.get(), 0);
completion::move_selection(&st, -1);
assert_eq!(
st.borrow().completion.selected.get(),
2,
"up from the top wraps to the end"
);
completion::move_selection(&st, 1);
assert_eq!(
st.borrow().completion.selected.get(),
0,
"down from the end wraps to the top"
);
}
#[test]
fn the_completion_panel_mounts_with_a_session() {
let st = completion_editor("pre", 3, &["prefix", "press", "preset"]);
completion::test_evaluate(&st, Trigger::Typed);
st.borrow().completion.open.set(true);
let mut tree = WidgetTree::new();
tree.add(completion::CompletionPanel::new(&st));
tree.layout(SizeProposal::with_width(360.0));
let _ = tree.render();
}
#[test]
fn the_completion_panel_is_empty_without_a_session() {
let st = completion_editor("", 0, &["foo"]);
let mut tree = WidgetTree::new();
let id = tree.add(completion::CompletionPanel::new(&st));
tree.layout(SizeProposal::with_width(360.0));
assert_eq!(tree.bounds(id).height, 0.0, "no rows → no height");
}
#[test]
fn a_code_editor_with_completion_mounts() {
let mut tree = WidgetTree::new();
let ed = CodeEditor::new(doc("fn main() {}"))
.completion_provider(|_cx| vec![CompletionItem::new("main"), CompletionItem::new("map")]);
let id = tree.add(ed);
tree.layout(SizeProposal::exact(600.0, 400.0));
assert_eq!(tree.bounds(id).width, 600.0);
let _ = tree.render();
}
#[test]
fn a_provider_may_read_the_editor_state_without_panicking() {
let st = editor_cfg("pre", CodeConfig::default());
st.borrow().cursor.set_position(3, MoveMode::MoveAnchor);
let probe = st.clone();
st.borrow_mut().completion.provider = Some(std::rc::Rc::new(move |_cx| {
let _ = probe.borrow().cursor.position();
vec![CompletionItem::new("prefix")]
}));
completion::test_evaluate(&st, Trigger::Typed);
assert_eq!(st.borrow().completion.test_labels(), vec!["prefix"]);
}
#[test]
fn identifier_end_extends_through_the_word() {
let st = editor_cfg("list.pushx", CodeConfig::default());
assert_eq!(semantics::identifier_end(&st.borrow(), 7), 10);
}
#[test]
fn accept_replaces_the_whole_identifier_mid_word() {
let st = editor_cfg("list.puX", CodeConfig::default());
st.borrow().cursor.set_position(7, MoveMode::MoveAnchor);
{
let mut s = st.borrow_mut();
let end = semantics::identifier_end(&s, 7);
semantics::accept_completion(&mut s, 5, end, "push");
}
assert_eq!(text_of(&st), "list.push", "the trailing X is replaced too");
}
#[test]
fn completion_does_not_activate_with_multiple_carets() {
let st = completion_editor("pre", 3, &["prefix", "press"]);
let extra = st.borrow().document.cursor();
st.borrow_mut().extra_carets.push(extra);
completion::test_evaluate(&st, Trigger::Typed);
assert!(
st.borrow().completion.test_labels().is_empty(),
"no completion session while multiple carets are live"
);
}
#[test]
fn a_forced_trigger_lifts_escape_suppression() {
let st = completion_editor("pre", 3, &["prefix", "press"]);
st.borrow_mut().completion.test_set_suppressed(Some(0));
completion::test_evaluate(&st, Trigger::Typed);
assert!(
st.borrow().completion.test_labels().is_empty(),
"suppressed: typing does not reopen"
);
completion::test_evaluate(&st, Trigger::Forced);
assert_eq!(
st.borrow().completion.test_labels(),
vec!["prefix", "press"],
"Forced lifts the suppression"
);
}
#[test]
fn a_plain_move_does_not_open_completion() {
let st = completion_editor("prefix", 6, &["prefix", "press"]);
completion::test_evaluate(&st, Trigger::Moved);
assert!(
st.borrow().completion.test_labels().is_empty(),
"arrowing onto a word must not open the popup"
);
}
mod log {
use super::*;
use crate::code_editor::log_stream::{self, LogStreamState};
fn log_state() -> SharedState {
let doc = TextDocument::new();
let st = construct(
doc,
CODE_READ_ONLY_PRESET,
CodeConfig::default(),
WrapMode::None,
);
st.borrow_mut().log = Some(LogStreamState::new());
st.borrow_mut()
.sync_viewport(Rect::new(0.0, 0.0, 400.0, 100.0));
st
}
fn enqueue(st: &SharedState, lines: &[&str]) {
let s = st.borrow();
let mut q = s.log.as_ref().unwrap().pending.lock().unwrap();
for l in lines {
q.push_back((*l).to_string());
}
}
fn enqueue_owned(st: &SharedState, lines: impl IntoIterator<Item = String>) {
let s = st.borrow();
let mut q = s.log.as_ref().unwrap().pending.lock().unwrap();
for l in lines {
q.push_back(l);
}
}
fn pump(st: &SharedState) {
let mut s = st.borrow_mut();
log_stream::tick(&mut s, 0.016);
}
#[test]
fn appending_grows_the_line_count() {
let st = log_state();
enqueue(&st, &["one", "two", "three"]);
pump(&st);
assert_eq!(st.borrow().line_count.get(), 3);
}
#[test]
fn the_first_line_is_not_preceded_by_a_blank() {
let st = log_state();
enqueue(&st, &["first"]);
pump(&st);
assert_eq!(st.borrow().line_count.get(), 1, "no phantom blank line 0");
let text = st
.borrow()
.document
.snapshot_block_at_position(0)
.unwrap()
.text;
assert_eq!(text, "first");
}
#[test]
fn selecting_within_the_window_rewalks_the_accessibility_selection() {
let st = log_state();
enqueue(&st, &["alpha", "bravo", "charlie"]);
pump(&st);
let mut tree = WidgetTree::new();
let _ = tree.add(crate::code_editor::log_view::log_body_for(&st));
tree.layout(SizeProposal::exact(400.0, 100.0));
let base = tree.sync_accessibility();
assert_eq!(
super::resolved_caret(&base, &st),
Some(0),
"baseline caret at 0"
);
st.borrow().cursor.set_position(2, MoveMode::MoveAnchor);
crate::code_editor::sync_cursor_signals(&st);
tree.layout(SizeProposal::exact(400.0, 100.0));
let after = tree.sync_accessibility();
assert_eq!(
super::resolved_caret(&after, &st),
Some(2),
"the log's AT selection must follow a within-window caret move",
);
}
#[test]
fn a_streaming_append_does_not_force_a_full_relayout() {
let st = log_state();
st.borrow_mut().needs_full_layout = false;
enqueue(&st, &["a", "b"]);
pump(&st);
assert!(
!st.borrow().needs_full_layout,
"streaming must re-window, never force a full relayout"
);
}
#[test]
fn the_extent_spans_the_whole_document() {
let st = log_state();
enqueue_owned(&st, (0..1000).map(|i| format!("line {i}")));
pump(&st);
let s = st.borrow();
let row_h = s.log.as_ref().unwrap().row_height;
assert!(row_h > 0.0, "the row height must have been learned");
assert!(
(s.engine.content_height() - 1000.0 * row_h).abs() < 1.0,
"content_height must span all 1000 rows"
);
}
#[test]
fn following_the_tail_sticks_to_the_bottom() {
let st = log_state();
enqueue_owned(&st, (0..200).map(|i| format!("line {i}")));
pump(&st);
let s = st.borrow();
assert!(s.max_scroll_y.get() > 0.0, "200 lines must overflow");
assert!(
(s.scroll_y.get() - s.max_scroll_y.get()).abs() < 2.0,
"a following view must be parked at the bottom"
);
}
#[test]
fn following_survives_eviction() {
let st = log_state();
st.borrow_mut().log.as_mut().unwrap().scrollback_limit = Some(20);
enqueue_owned(&st, (0..30).map(|i| format!("line {i}")));
pump(&st);
assert!(
(st.borrow().scroll_y.get() - st.borrow().max_scroll_y.get()).abs() < 2.0,
"precondition: following at the bottom"
);
enqueue_owned(&st, (30..400).map(|i| format!("line {i}")));
pump(&st);
let s = st.borrow();
assert!(
(s.scroll_y.get() - s.max_scroll_y.get()).abs() < 2.0,
"eviction+growth in one tick must not break follow: scroll_y={}, max={}",
s.scroll_y.get(),
s.max_scroll_y.get()
);
}
#[test]
fn scrolling_up_pauses_the_follow() {
let st = log_state();
enqueue_owned(&st, (0..100).map(|i| format!("line {i}")));
pump(&st);
st.borrow().scroll_y.set(0.0);
pump(&st);
enqueue(&st, &["new one", "new two"]);
pump(&st);
assert!(
st.borrow().scroll_y.get() < 5.0,
"reading history must not be interrupted by new output"
);
}
#[test]
fn follow_disabled_holds_position() {
let st = log_state();
st.borrow_mut().log.as_mut().unwrap().follow_enabled = false;
enqueue_owned(&st, (0..200).map(|i| format!("line {i}")));
pump(&st);
assert!(
st.borrow().scroll_y.get() < 1.0,
"a non-following view holds at the top as it grows"
);
}
#[test]
fn windowing_resolves_the_correct_rows_after_scroll() {
let st = log_state();
enqueue_owned(&st, (0..500).map(|i| format!("line {i}")));
pump(&st);
let row_h = st.borrow().log.as_ref().unwrap().row_height;
st.borrow().scroll_y.set(250.0 * row_h);
pump(&st);
let (arow, apos) = st.borrow().log.as_ref().unwrap().anchor.unwrap();
assert_eq!(arow, 250, "anchor row must match the scroll");
let text = st
.borrow()
.document
.snapshot_block_at_position(apos)
.unwrap()
.text;
assert_eq!(
text, "line 250",
"the anchor must resolve to the right block"
);
}
#[test]
fn scrollback_limit_bounds_the_buffer() {
let st = log_state();
st.borrow_mut().log.as_mut().unwrap().scrollback_limit = Some(50);
enqueue_owned(&st, (0..1000).map(|i| format!("line {i}")));
pump(&st);
let count = st.borrow().line_count.get();
assert!(
(50..=50 + 256).contains(&count),
"bounded near the cap, was {count}"
);
}
#[test]
fn a_small_scrollback_limit_is_honoured_tightly() {
let st = log_state();
st.borrow_mut().log.as_mut().unwrap().scrollback_limit = Some(10);
enqueue_owned(&st, (0..500).map(|i| format!("line {i}")));
pump(&st);
let count = st.borrow().line_count.get();
assert!(count <= 12, "a small cap must be tight, was {count}");
}
#[test]
fn the_severity_classifier_sees_line_text() {
use std::cell::RefCell;
use std::rc::Rc;
let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
let st = log_state();
{
let seen = seen.clone();
st.borrow_mut().log.as_mut().unwrap().severity = Some(Rc::new(move |text: &str| {
seen.borrow_mut().push(text.to_string());
None
}));
}
enqueue(&st, &["alpha", "beta", "gamma"]);
pump(&st);
assert!(
seen.borrow().iter().any(|t| t == "alpha"),
"the classifier must see each visible line's text, saw {:?}",
seen.borrow()
);
}
#[test]
fn the_handle_splits_on_newlines() {
let st = log_state();
let handle = crate::code_editor::LogViewHandle::from_state_for_test(st.clone());
handle.append("a\nb\nc\n");
pump(&st);
assert_eq!(
st.borrow().line_count.get(),
3,
"three lines, no blank from the trailing newline"
);
}
#[test]
fn clearing_resets_the_view() {
let st = log_state();
enqueue(&st, &["a", "b", "c"]);
pump(&st);
assert_eq!(st.borrow().line_count.get(), 3);
let handle = crate::code_editor::LogViewHandle::from_state_for_test(st.clone());
handle.clear();
pump(&st);
assert_eq!(
st.borrow().line_count.get(),
0,
"an emptied log has no lines"
);
assert!(st.borrow().log.as_ref().unwrap().pristine, "pristine again");
assert_eq!(st.borrow().scroll_y.get(), 0.0);
handle.append("after clear");
pump(&st);
assert_eq!(st.borrow().line_count.get(), 1);
let text = st
.borrow()
.document
.snapshot_block_at_position(0)
.unwrap()
.text;
assert_eq!(text, "after clear", "refill must not leave a blank line 0");
}
#[test]
fn the_a11y_tree_is_windowed() {
use teksilo_core::accesskit::Role;
use teksilo_core::widget_id::WidgetId;
let st = log_state();
enqueue_owned(&st, (0..1000).map(|i| format!("line {i}")));
pump(&st);
let mut b = teksilo_core::accessibility::AccessNodeBuilder::for_widget(WidgetId::default());
crate::code_editor::a11y::build_log_a11y(&st.borrow(), &mut b);
let (_id, _n, children) = b.build(WidgetId::default());
let paras: Vec<_> = children
.iter()
.filter(|(_, n)| n.role() == Role::Paragraph)
.map(|(_, n)| n)
.collect();
assert!(!paras.is_empty(), "some visible lines are exposed");
assert!(
paras.len() < 100,
"the tree is windowed, not all 1000 lines: got {}",
paras.len()
);
assert_eq!(
paras[0].size_of_set(),
Some(1000),
"a line is 'N of 1000', not 'N of window'"
);
}
#[test]
fn a11y_version_bumps_only_when_the_window_changes() {
let st = log_state();
enqueue_owned(&st, (0..200).map(|i| format!("line {i}")));
pump(&st);
let ver = st.borrow().log.as_ref().unwrap().a11y_version.clone();
let v0 = ver.get();
enqueue(&st, &["new"]);
pump(&st);
assert!(ver.get() > v0, "a following append must bump a11y_version");
st.borrow().scroll_y.set(0.0);
pump(&st);
let v1 = ver.get();
enqueue(&st, &["another"]);
pump(&st);
assert_eq!(
ver.get(),
v1,
"a tail append while scrolled away must not bump"
);
let row_h = st.borrow().log.as_ref().unwrap().row_height;
let v2 = ver.get();
st.borrow().scroll_y.set(row_h * 0.3);
pump(&st);
assert_eq!(ver.get(), v2, "a sub-row pixel scroll must not bump");
let v3 = ver.get();
st.borrow().scroll_y.set(row_h * 5.0);
pump(&st);
assert!(
ver.get() > v3,
"crossing to a new row must bump a11y_version"
);
}
#[test]
fn the_total_count_refreshes_on_a_throttle_while_scrolled_away() {
let st = log_state();
enqueue_owned(&st, (0..200).map(|i| format!("line {i}")));
pump(&st);
st.borrow().scroll_y.set(0.0);
pump(&st);
let ver = st.borrow().log.as_ref().unwrap().a11y_version.clone();
let v0 = ver.get();
enqueue(&st, &["off-window"]);
pump(&st);
assert_eq!(ver.get(), v0, "one off-window append must not re-walk");
enqueue(&st, &["later"]);
{
let mut s = st.borrow_mut();
log_stream::tick(&mut s, log_stream::A11Y_TOTAL_REFRESH_SECS + 0.1);
}
assert!(
ver.get() > v0,
"an off-window append past the throttle must refresh the total"
);
}
}