use std::cell::Cell;
use std::rc::Rc;
use teksilo_canvas::{Point, SizeProposal};
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
use teksilo_text::text_document::TextDocument;
use super::RichTextEditor;
fn tree_with_layout() -> WidgetTree {
let mut tree = WidgetTree::new();
tree.layout(SizeProposal::exact(400.0, 300.0));
tree
}
fn tick_once(tree: &mut WidgetTree) {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(400.0, 300.0));
}
#[test]
fn read_only_constructs_and_exposes_version_signal() {
let doc = TextDocument::new();
doc.set_plain_text("Hello, world!").unwrap();
let editor = RichTextEditor::read_only(doc);
let version = editor.document_version();
assert_eq!(
version.get(),
0,
"freshly built editor reports version 0 until it drains events"
);
}
#[test]
fn read_only_widget_places_into_tree_and_lays_out() {
let doc = TextDocument::new();
doc.set_plain_text("First paragraph.\n\nSecond paragraph with more text.")
.unwrap();
let mut tree = WidgetTree::new();
let _id = tree.add(RichTextEditor::read_only(doc.clone()));
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
tree.frame_requested() || tree.needs_redraw(),
"the editor must request a frame during build so its frame loop kicks in"
);
for _ in 0..3 {
tick_once(&mut tree);
}
}
#[test]
fn shared_document_between_two_editors_delivers_events_independently() {
let doc = TextDocument::new();
doc.set_plain_text("initial").unwrap();
let a = RichTextEditor::read_only(doc.clone());
let b = RichTextEditor::read_only(doc.clone());
let version_a = a.document_version();
let version_b = b.document_version();
let mut tree = WidgetTree::new();
let _ia = tree.add(a);
let _ib = tree.add(b);
tree.layout(SizeProposal::exact(400.0, 300.0));
let before_a = version_a.get();
let before_b = version_b.get();
doc.set_plain_text("updated by external mutation").unwrap();
tick_once(&mut tree);
assert!(
version_a.get() > before_a,
"editor A must see the external mutation (version was {}, now {})",
before_a,
version_a.get()
);
assert!(
version_b.get() > before_b,
"editor B must see the external mutation (version was {}, now {})",
before_b,
version_b.get()
);
}
#[test]
fn select_all_updates_has_selection_signal() {
let doc = TextDocument::new();
doc.set_plain_text("some text").unwrap();
let editor = RichTextEditor::read_only(doc);
let has_sel = editor.has_selection();
assert!(!has_sel.get(), "initial state: nothing selected");
editor.select_all();
assert!(
has_sel.get(),
"select_all must flip the has_selection signal"
);
assert!(!editor.selected_text().is_empty());
editor.deselect();
assert!(
!has_sel.get(),
"deselect must flip has_selection back to false"
);
}
#[test]
fn context_target_reports_plain_text_for_empty_document() {
let doc = TextDocument::new();
doc.set_plain_text("line").unwrap();
let editor = RichTextEditor::read_only(doc);
let _ = editor.context_target_at(Point::new(5.0, 5.0));
}
#[test]
fn idle_read_only_editor_does_not_pump_frames_after_events_drain() {
let doc = TextDocument::new();
doc.set_plain_text("steady").unwrap();
let mut tree = WidgetTree::new();
let _ = tree.add(RichTextEditor::read_only(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
for _ in 0..5 {
tick_once(&mut tree);
}
assert!(
!tree.frame_requested(),
"an idle read-only editor must stop requesting frames"
);
}
#[test]
fn read_only_editor_arrow_keys_move_caret_and_preserve_selection() {
use teksilo_canvas::Point;
use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::read_only(doc);
let caret = editor.cursor_position_signal();
let has_sel = editor.has_selection();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(1.0, 8.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let focused = tree.focused();
assert!(
focused.is_some_and(|f| f == id || tree.is_descendant_of(f, id)),
"click must focus editor (focus={:?})",
focused,
);
let after_click = caret.get();
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
caret.get(),
after_click + 1,
"ArrowRight must advance caret by 1 (was {}, now {})",
after_click,
caret.get()
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::SHIFT,
text: None,
});
assert_eq!(caret.get(), after_click + 2);
assert!(has_sel.get(), "Shift+ArrowRight must flip has_selection");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::End,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
caret.get(),
"Hello world".chars().count(),
"End must move to block end"
);
assert!(
!has_sel.get(),
"End without shift must collapse the selection"
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::Home,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(caret.get(), 0, "Home must move to block start");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::A,
modifiers: Modifiers::CTRL,
text: None,
});
assert!(has_sel.get(), "Ctrl+A must set has_selection");
}
#[test]
fn read_only_editor_end_key_does_not_escalate_past_block() {
use teksilo_canvas::Point;
use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("first block\nsecond block").unwrap();
let editor = RichTextEditor::read_only(doc);
let caret = editor.cursor_position_signal();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(1.0, 8.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::End,
modifiers: Modifiers::NONE,
text: None,
});
let after_first_end = caret.get();
assert!(after_first_end > 0, "first End must move caret");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::End,
modifiers: Modifiers::NONE,
text: None,
});
let after_second_end = caret.get();
assert_eq!(
after_second_end, after_first_end,
"second End must stay at end of current block (was {}, now {})",
after_first_end, after_second_end
);
}
#[test]
fn read_only_editor_blink_policy_stays_off_when_unfocused() {
let doc = TextDocument::new();
doc.set_plain_text("quiet").unwrap();
let mut tree = WidgetTree::new();
let _ = tree.add(RichTextEditor::read_only(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
assert!(
!tree.frame_requested(),
"an unfocused read-only editor must not chain-request frames"
);
}
#[test]
fn read_only_editor_is_focusable_and_dispatches_key_events() {
use teksilo_canvas::Point;
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("abcdef").unwrap();
let editor = RichTextEditor::read_only(doc);
let version = editor.document_version();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(100.0, 20.0),
button: teksilo_core::event::PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let focused = tree.focused();
assert!(
focused.is_some_and(|f| f == id || tree.is_descendant_of(f, id)),
"clicking the editor must focus it (focus={:?})",
focused,
);
let version_before = version.get();
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
let _ = version_before; assert!(
tree.frame_requested(),
"on_key must call ctx.request_frame() when an arrow key is handled"
);
}
#[test]
fn read_only_editor_with_shared_typesetter_also_renders_glyphs() {
use std::any::TypeId;
use std::collections::HashMap;
use teksilo_text::SharedTypesetter;
let doc = TextDocument::new();
doc.set_plain_text("Alpha Beta Gamma").unwrap();
let mut tree = WidgetTree::new();
let shared = SharedTypesetter::new_with_default_font();
tree = tree.with_text_backend(shared.as_text_backend());
let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
registry.insert(TypeId::of::<SharedTypesetter>(), Box::new(shared));
let ctx = teksilo_core::event_source::TreeAppContext::empty().with_app_state(registry);
tree.set_app_context(std::rc::Rc::new(ctx));
let _ = tree.add(RichTextEditor::read_only(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
let frame = tree.render();
assert!(
!frame.glyphs.is_empty(),
"shared-typesetter editor must emit glyphs, got {}",
frame.glyphs.len()
);
}
#[test]
fn read_only_editor_emits_glyphs_into_final_render_frame() {
let doc = TextDocument::new();
doc.set_plain_text("Alpha Beta Gamma Delta Epsilon")
.unwrap();
let mut tree = WidgetTree::new();
let id = tree.add(RichTextEditor::read_only(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0 && bounds.height > 0.0);
let frame = tree.render();
assert!(
!frame.glyphs.is_empty(),
"expected the read-only editor to emit glyph quads into the rendered frame, got {} glyphs",
frame.glyphs.len()
);
assert!(
frame
.glyphs
.iter()
.any(|g| g.screen[0] < 400.0 && g.screen[1] < 300.0),
"glyph coordinates must land inside the widget viewport"
);
}
fn tick_past_debounce(tree: &mut WidgetTree) {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(200));
tree.layout(SizeProposal::exact(400.0, 300.0));
}
fn press_key(
tree: &mut WidgetTree,
key: teksilo_core::event::Key,
mods: teksilo_core::event::Modifiers,
) {
use teksilo_core::event::WidgetEvent;
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: mods,
text: None,
});
}
fn press_char(tree: &mut WidgetTree, ch: char) {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::A, modifiers: Modifiers::NONE,
text: Some(ch.to_string()),
});
let _ = (tree, ch);
}
fn focus_editor(tree: &mut WidgetTree, id: teksilo_core::widget_id::WidgetId) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(20.0, 20.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let focused = tree.focused();
assert!(
focused.is_some_and(|f| f == id || tree.is_descendant_of(f, id)),
"focus_editor helper: click did not focus editor (focus={:?}, expected {:?} or a descendant)",
focused,
id,
);
}
#[test]
fn editor_inserts_typed_characters_via_pending_chars_batch() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
for ch in [' ', 'd', 'e', 'f'] {
press_char(&mut tree, ch);
}
for _ in 0..3 {
tick_once(&mut tree);
}
let plain = doc.to_plain_text().unwrap_or_default();
assert_eq!(
plain, "abc def",
"pending_chars batch must flush to a single insert — got {:?}",
plain
);
}
#[test]
fn editor_backspace_and_delete_remove_characters() {
let doc = TextDocument::new();
doc.set_plain_text("ABCDE").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "ABC");
press_key(
&mut tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::Delete,
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "BC");
}
#[test]
fn editor_enter_inserts_a_new_block() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::Enter,
teksilo_core::event::Modifiers::NONE,
);
for ch in ['s', 'e', 'c', 'o', 'n', 'd'] {
press_char(&mut tree, ch);
}
for _ in 0..3 {
tick_once(&mut tree);
}
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("first") && plain.contains("second") && plain.len() > "firstsecond".len(),
"Enter must insert a block boundary between 'first' and 'second', got {:?}",
plain
);
}
#[test]
fn editor_undo_redo_round_trip_restores_can_undo_signal() {
let doc = TextDocument::new();
doc.set_plain_text("base").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let can_undo = editor.can_undo();
let can_redo = editor.can_redo();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
for ch in ['!', '!'] {
press_char(&mut tree, ch);
}
tick_past_debounce(&mut tree);
tick_past_debounce(&mut tree);
let after_insert = doc.to_plain_text().unwrap_or_default();
assert_eq!(after_insert, "base!!");
assert!(
can_undo.get(),
"after insert can_undo must be true (published through debounce drain)"
);
press_key(
&mut tree,
teksilo_core::event::Key::Z,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"base",
"Ctrl+Z must revert the insert"
);
assert!(
can_redo.get(),
"after undo can_redo must be true — signal published via debounce"
);
press_key(
&mut tree,
teksilo_core::event::Key::Y,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"base!!",
"Ctrl+Y must reapply the insert"
);
}
#[test]
fn editor_bold_toggle_applies_to_selection() {
let doc = TextDocument::new();
doc.set_plain_text("text").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::B,
teksilo_core::event::Modifiers::CTRL,
);
let probe = state.borrow().document.cursor();
probe.set_position(1, teksilo_text::text_document::MoveMode::MoveAnchor);
let after = probe.char_format().unwrap_or_default();
assert_eq!(
after.font_bold,
Some(true),
"Ctrl+B on selection must bold the selected range — got {:?}",
after.font_bold
);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::B,
teksilo_core::event::Modifiers::CTRL,
);
probe.set_position(1, teksilo_text::text_document::MoveMode::MoveAnchor);
let after_off = probe.char_format().unwrap_or_default();
assert_eq!(
after_off.font_bold,
Some(false),
"Ctrl+B again must toggle the selection back to non-bold"
);
}
#[test]
fn read_only_preset_emits_no_cursor_decoration() {
let doc = TextDocument::new();
doc.set_plain_text("quiet").unwrap();
let mut tree = WidgetTree::new();
let id = tree.add(RichTextEditor::read_only(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
for _ in 0..3 {
tick_once(&mut tree);
}
let frame = tree.render();
use teksilo_canvas::DecorationKind;
assert!(
!frame
.decorations
.iter()
.any(|d| matches!(d.kind, DecorationKind::Cursor)),
"Hidden caret policy must not emit any DecorationKind::Cursor rects"
);
}
#[test]
fn caret_hidden_when_window_inactive() {
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
tick_once(&mut tree);
assert!(state.borrow().has_focus, "editor took focus");
assert!(state.borrow().window_active);
assert!(
state.borrow().caret_visible.get(),
"caret visible when focused in an active window"
);
tree.set_window_active(false);
assert!(!state.borrow().window_active);
assert!(
!state.borrow().caret_visible.get(),
"caret_visible cleared on window blur"
);
tree.set_window_active(true);
assert!(state.borrow().window_active);
assert!(
state.borrow().caret_visible.get(),
"caret restored on window reactivate"
);
}
#[test]
fn selection_color_desaturates_when_window_inactive() {
let theme = teksilo_core::presets::intui::light();
let active_color = theme.colors.editor_selection_bg.to_array();
let inactive_color = theme.colors.selection_bg_inactive.to_array();
assert_ne!(active_color, inactive_color);
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new().with_theme(theme);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
let _ = tree.render();
assert_eq!(
state.borrow().last_selection_color,
Some(active_color),
"active window uses the vivid editor selection colour"
);
tree.set_window_active(false);
let _ = tree.render();
assert_eq!(
state.borrow().last_selection_color,
Some(inactive_color),
"inactive window desaturates the selection"
);
tree.set_window_active(true);
let _ = tree.render();
assert_eq!(
state.borrow().last_selection_color,
Some(active_color),
"reactivating restores the vivid selection"
);
}
#[test]
fn a_reactivated_editor_re_arms_its_frame_loop() {
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.set_dormant(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
state.borrow().frame_request.as_ref().unwrap().set(false);
tree.activate(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
state.borrow().frame_request.as_ref().unwrap().get(),
"a re-activated editor must re-arm its frame loop, or its caret never \
blinks again and a modal opens caretless"
);
}
#[test]
fn custom_selection_color_is_not_desaturated_when_inactive() {
use teksilo_tokens::Color;
let custom = Color::from_hex("#FF00FF");
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc).selection_color(custom);
let state = editor.state_handle();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
let _ = tree.render();
assert_eq!(state.borrow().last_selection_color, Some(custom.to_array()));
tree.set_window_active(false);
let _ = tree.render();
assert_eq!(
state.borrow().last_selection_color,
Some(custom.to_array()),
"custom selection colour stays fixed while the window is inactive"
);
}
#[test]
fn caret_color_follows_theme_in_dark_mode() {
use teksilo_text::{CursorAffinity, CursorDisplay, TypesetterDecorationKind as DecorationKind};
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::dark());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
let _ = tree.render();
let expected = teksilo_core::presets::intui::dark()
.colors
.editor_caret
.to_array();
assert_ne!(
expected,
[0.0, 0.0, 0.0, 1.0],
"dark theme's editor_caret must not be black, or this test proves nothing"
);
let mut st = state.borrow_mut();
st.engine.set_cursor(&CursorDisplay {
position: 0,
anchor: 0,
affinity: CursorAffinity::Downstream,
visible: true,
selected_cells: Vec::new(),
});
let caret_color = st.engine.with_render_cursor_only(|frame| {
frame
.decorations
.iter()
.find(|d| matches!(d.kind, DecorationKind::Cursor))
.map(|d| d.color)
});
assert_eq!(
caret_color,
Some(expected),
"caret must paint in the theme's editor_caret colour, not the engine's black default"
);
}
fn ctx_with_memory_clipboard(
tree: &mut WidgetTree,
) -> teksilo_platform::clipboard::ClipboardHandle {
use std::any::TypeId;
use std::collections::HashMap;
use teksilo_core::event_source::TreeAppContext;
use teksilo_platform::clipboard::{ClipboardHandle, MemoryClipboard};
let handle = ClipboardHandle::new(MemoryClipboard::new());
let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
registry.insert(TypeId::of::<ClipboardHandle>(), Box::new(handle.clone()));
let ctx = TreeAppContext::empty().with_app_state(registry);
tree.set_app_context(std::rc::Rc::new(ctx));
handle
}
fn synth_pointer_down(tree: &mut WidgetTree, x: f32, y: f32) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn synth_pointer_up(tree: &mut WidgetTree, x: f32, y: f32) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn synth_pointer_move(tree: &mut WidgetTree, x: f32, y: f32) {
use teksilo_core::event::WidgetEvent;
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(x, y),
});
}
#[test]
fn editor_copy_sets_system_clipboard_plain_text() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::C,
teksilo_core::event::Modifiers::CTRL,
);
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"Ctrl+C must push the selection's plain text into the clipboard"
);
}
#[test]
fn editor_cut_removes_selection_and_fills_clipboard() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::X,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"Ctrl+X must copy the selection before removing"
);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"",
"Ctrl+X must remove the selection from the document"
);
}
#[test]
fn editor_paste_inserts_system_clipboard_text() {
let doc = TextDocument::new();
doc.set_plain_text("start ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_text("pasted").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "start pasted");
}
#[test]
fn editor_copy_paste_round_trip_uses_stored_fragment() {
let doc = TextDocument::new();
doc.set_plain_text("one two three").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::CTRL,
);
for _ in 0..3 {
press_key(
&mut tree,
teksilo_core::event::Key::ArrowRight,
teksilo_core::event::Modifiers::SHIFT,
);
}
press_key(
&mut tree,
teksilo_core::event::Key::C,
teksilo_core::event::Modifiers::CTRL,
);
{
let st = state.borrow();
assert!(
st.rich_clipboard_fragment.is_some(),
"Ctrl+C must stash the rich fragment"
);
assert!(
st.rich_clipboard_marker.is_some(),
"Ctrl+C must stash the marker that identifies the payload as ours"
);
assert_eq!(
st.rich_clipboard_plain, None,
"this backend carries the marker, so the plain-text identity — the \
fallback for backends that cannot — must stay unset: another \
application's identical text would otherwise pick up our fragment"
);
}
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"one two threeone",
"paste must append 'one' at end via the stored fragment"
);
}
#[test]
fn double_click_selects_word() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo charlie").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
synth_pointer_down(&mut tree, 50.0, 8.0);
synth_pointer_up(&mut tree, 50.0, 8.0);
synth_pointer_down(&mut tree, 50.0, 8.0);
synth_pointer_up(&mut tree, 50.0, 8.0);
let selected = state.borrow().cursor.selected_text().unwrap_or_default();
assert!(
!selected.is_empty() && "alpha bravo charlie".contains(selected.as_str()),
"double-click must select a word, got {:?}",
selected
);
assert!(
!selected.contains(' '),
"double-click must select a single word, not a span with whitespace"
);
}
#[test]
fn triple_click_selects_block() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo charlie").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
for _ in 0..3 {
synth_pointer_down(&mut tree, 50.0, 8.0);
synth_pointer_up(&mut tree, 50.0, 8.0);
}
let selected = state.borrow().cursor.selected_text().unwrap_or_default();
assert_eq!(
selected, "alpha bravo charlie",
"triple-click must select the whole block, got {:?}",
selected
);
}
#[test]
fn click_after_double_click_does_not_block_arena() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo charlie").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
synth_pointer_down(&mut tree, 50.0, 8.0);
synth_pointer_up(&mut tree, 50.0, 8.0);
let after_click_1 = state.borrow().cursor.selected_text().unwrap_or_default();
assert!(
after_click_1.is_empty(),
"click 1 must not produce a selection (got {:?})",
after_click_1
);
synth_pointer_down(&mut tree, 50.0, 8.0);
synth_pointer_up(&mut tree, 50.0, 8.0);
let after_click_2 = state.borrow().cursor.selected_text().unwrap_or_default();
assert!(
!after_click_2.is_empty(),
"click 2 must fire on_double_tap and select a word"
);
}
#[test]
fn drag_select_extends_selection() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo charlie delta echo")
.unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
synth_pointer_down(&mut tree, 1.0, 8.0);
let anchor_before = state.borrow().cursor.anchor();
synth_pointer_move(&mut tree, 120.0, 8.0);
let st = state.borrow();
assert!(
st.cursor.has_selection(),
"drag must extend a selection from the press position"
);
assert_eq!(
st.cursor.anchor(),
anchor_before,
"anchor must stay at the press position during drag"
);
}
#[test]
fn ctrl_a_outside_table_is_single_shot_document() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo charlie").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
let first = state.borrow().cursor.selected_text().unwrap_or_default();
let first_level = state.borrow().select_all_level;
assert_eq!(first, "alpha bravo charlie");
assert_eq!(
first_level, 0,
"non-table Ctrl+A stays at level 0 (single-shot Document)"
);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
let second = state.borrow().cursor.selected_text().unwrap_or_default();
assert_eq!(second, "alpha bravo charlie");
assert_eq!(state.borrow().select_all_level, 0);
}
#[test]
fn ctrl_a_reset_on_other_key() {
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo").unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::ArrowRight,
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(
state.borrow().select_all_level,
0,
"any non-SelectAll key resets select_all_level to 0"
);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
assert_eq!(state.borrow().select_all_level, 0);
assert_eq!(
state.borrow().cursor.selected_text().unwrap_or_default(),
"alpha bravo"
);
}
#[test]
fn editor_copy_writes_both_plain_text_and_html() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::C,
teksilo_core::event::Modifiers::CTRL,
);
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"plain-text payload must be present"
);
assert!(
clipboard.has_html(),
"copy must also write an HTML payload via DocumentFragment::to_html"
);
let html = clipboard
.get_html()
.expect("HTML payload must be readable after copy");
assert!(
html.contains("Hello world"),
"serialised HTML must carry the copied text, got {:?}",
html
);
}
#[test]
fn editor_paste_prefers_self_round_trip_over_html() {
let doc = TextDocument::new();
doc.set_plain_text("one two three").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::CTRL,
);
for _ in 0..3 {
press_key(
&mut tree,
teksilo_core::event::Key::ArrowRight,
teksilo_core::event::Modifiers::SHIFT,
);
}
press_key(
&mut tree,
teksilo_core::event::Key::C,
teksilo_core::event::Modifiers::CTRL,
);
assert!(
state.borrow().rich_clipboard_fragment.is_some(),
"copy stashes the fragment"
);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"one two threeone",
"self-round-trip fragment must land intact after Ctrl+C / Ctrl+V"
);
}
#[test]
fn editor_paste_from_external_html_inserts_rich_content() {
let doc = TextDocument::new();
doc.set_plain_text("before ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html("<p><b>BOLD</b></p>", "BOLD").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("BOLD"),
"HTML paste must insert the content's text ({:?})",
plain
);
let b_pos = plain.find("BOLD").expect("BOLD substring");
let probe = doc.cursor();
probe.set_position(b_pos, teksilo_text::text_document::MoveMode::MoveAnchor);
let fmt = probe.char_format().unwrap_or_default();
assert_eq!(
fmt.font_bold,
Some(true),
"HTML <b> tag must translate to TextFormat.font_bold = true, got {:?}",
fmt.font_bold
);
}
#[test]
fn editor_paste_falls_back_to_plain_when_html_unsupported() {
use std::any::TypeId;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use teksilo_core::event_source::TreeAppContext;
use teksilo_platform::clipboard::{ClipboardBackend, ClipboardHandle};
struct PlainOnly(Rc<RefCell<String>>);
impl ClipboardBackend for PlainOnly {
fn get_text(&mut self) -> Result<String, String> {
Ok(self.0.borrow().clone())
}
fn set_text(&mut self, text: &str) -> Result<(), String> {
*self.0.borrow_mut() = text.to_string();
Ok(())
}
}
let text = Rc::new(RefCell::new("pasted".to_string()));
let handle = ClipboardHandle::new(PlainOnly(text.clone()));
let doc = TextDocument::new();
doc.set_plain_text("start ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
registry.insert(TypeId::of::<ClipboardHandle>(), Box::new(handle.clone()));
let ctx = TreeAppContext::empty().with_app_state(registry);
tree.set_app_context(std::rc::Rc::new(ctx));
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"start pasted",
"paste must still work when the backend lacks HTML support"
);
assert!(!handle.has_html(), "plain-only backend never reports HTML");
}
#[test]
fn editor_paste_unformatted_strips_html_to_plain() {
let doc = TextDocument::new();
doc.set_plain_text("before ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html("<p><b>BOLD</b></p>", "BOLD").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL | teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("BOLD"),
"plain text must be inserted verbatim, got {:?}",
plain
);
let b_pos = plain.find("BOLD").expect("BOLD substring");
let probe = doc.cursor();
probe.set_position(b_pos, teksilo_text::text_document::MoveMode::MoveAnchor);
let fmt = probe.char_format().unwrap_or_default();
assert!(
!matches!(fmt.font_bold, Some(true)),
"Paste Unformatted must not apply bold formatting — got font_bold = {:?}",
fmt.font_bold
);
}
#[test]
fn handle_copy_sets_system_clipboard_plain_text() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
handle.select_all();
tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.copy(ctx));
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"EditorHandle::copy must push the selection's plain text into the clipboard"
);
}
#[test]
fn handle_copy_without_selection_is_noop() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.copy(ctx));
assert!(
clipboard.get_text().unwrap_or_default().is_empty(),
"EditorHandle::copy with no selection must not write the clipboard"
);
}
#[test]
fn handle_cut_removes_selection_and_fills_clipboard() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
handle.select_all();
tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.cut(ctx));
tick_past_debounce(&mut tree);
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"EditorHandle::cut must copy the selection before removing"
);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"",
"EditorHandle::cut must remove the selection from the document"
);
}
#[test]
fn handle_paste_inserts_system_clipboard_text() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("start ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_text("pasted").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.paste(ctx));
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "start pasted");
}
#[test]
fn handle_paste_unformatted_strips_html_to_plain() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("before ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html("<p><b>BOLD</b></p>", "BOLD").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.paste_unformatted(ctx));
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("BOLD"),
"EditorHandle::paste_unformatted must insert the plain text verbatim, got {:?}",
plain
);
let b_pos = plain.find("BOLD").expect("BOLD substring");
let probe = doc.cursor();
probe.set_position(b_pos, teksilo_text::text_document::MoveMode::MoveAnchor);
let fmt = probe.char_format().unwrap_or_default();
assert!(
!matches!(fmt.font_bold, Some(true)),
"EditorHandle::paste_unformatted must not apply bold formatting — got font_bold = {:?}",
fmt.font_bold
);
}
#[test]
fn handle_select_all_then_delete_selection_empties_document() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
!handle.has_selection().get(),
"a freshly built editor must start with no selection"
);
handle.select_all();
assert!(
handle.has_selection().get(),
"EditorHandle::select_all must produce a non-empty selection"
);
handle.delete_selection();
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"",
"EditorHandle::delete_selection must remove the selected range"
);
assert!(
!handle.has_selection().get(),
"the selection must be cleared once the range is deleted"
);
}
#[test]
fn handle_delete_selection_without_selection_is_noop() {
let doc = TextDocument::new();
doc.set_plain_text("Hello").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
handle.delete_selection();
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"Hello",
"EditorHandle::delete_selection with no selection must be a no-op"
);
}
#[test]
fn handle_can_paste_reflects_clipboard_contents() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("x").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let empty = std::cell::Cell::new(true);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| empty.set(handle.can_paste(ctx)));
assert!(
!empty.get(),
"can_paste must be false while the clipboard is empty"
);
clipboard.set_text("something").unwrap();
let filled = std::cell::Cell::new(false);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| filled.set(handle.can_paste(ctx)));
assert!(
filled.get(),
"can_paste must be true once the clipboard holds text"
);
}
#[test]
fn handle_can_paste_true_for_html_only_clipboard() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("x").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html("<p><b>rich</b></p>", "").unwrap();
assert!(
!clipboard.has_text(),
"precondition: the HTML-only clipboard must have no plain text"
);
assert!(clipboard.has_html(), "precondition: HTML payload present");
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let can = std::cell::Cell::new(false);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| can.set(handle.can_paste(ctx)));
assert!(
can.get(),
"can_paste must be true for an HTML-only clipboard — paste() would insert the HTML"
);
}
#[test]
fn handle_can_paste_false_without_clipboard_backend() {
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("x").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let can = std::cell::Cell::new(true);
tree.run_with_event_context(&mut NoopWindowOps, |ctx| can.set(handle.can_paste(ctx)));
assert!(
!can.get(),
"can_paste must degrade to false when no clipboard backend is installed"
);
}
#[test]
fn editor_ime_composition_then_commit_inserts_finalised_text() {
use teksilo_core::event::WidgetEvent;
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "n".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
assert_eq!(doc.to_plain_text().unwrap_or_default(), "n");
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "ni".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
assert_eq!(doc.to_plain_text().unwrap_or_default(), "ni");
tree.dispatch_event(WidgetEvent::ImeCommit {
text: "你".to_string(),
});
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"你",
"commit must replace the preedit with the final character"
);
}
#[test]
fn editor_ime_composition_cancelled_leaves_document_clean() {
use teksilo_core::event::WidgetEvent;
let doc = TextDocument::new();
doc.set_plain_text("before ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "abc".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
assert_eq!(doc.to_plain_text().unwrap_or_default(), "before abc");
tree.dispatch_event(WidgetEvent::ImeComposition {
text: String::new(),
cursor: None,
});
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"before ",
"cancelled composition must leave no residue"
);
}
#[test]
fn editor_paste_external_identical_plain_does_not_reuse_stale_fragment() {
let doc = TextDocument::new();
doc.set_plain_text("foo").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::CTRL,
);
for _ in 0..3 {
press_key(
&mut tree,
teksilo_core::event::Key::ArrowRight,
teksilo_core::event::Modifiers::SHIFT,
);
}
press_key(
&mut tree,
teksilo_core::event::Key::B,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::C,
teksilo_core::event::Modifiers::CTRL,
);
clipboard.set_text("foo").unwrap();
doc.set_plain_text("").unwrap();
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("foo"),
"plain paste must succeed, got {plain:?}"
);
let probe = doc.cursor();
probe.set_position(
plain.find("foo").unwrap(),
teksilo_text::text_document::MoveMode::MoveAnchor,
);
let fmt = probe.char_format().unwrap_or_default();
assert!(
!matches!(fmt.font_bold, Some(true)),
"external plain paste must NOT reuse stale bold fragment — got {:?}",
fmt.font_bold
);
}
#[test]
fn editor_paste_plain_text_with_newlines_splits_into_blocks() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard
.set_text("line one\nline two\nline three")
.unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(
doc.block_count(),
3,
"multi-line paste must split into 3 blocks, got {} with plain {:?}",
doc.block_count(),
doc.to_plain_text().unwrap_or_default()
);
}
#[test]
fn editor_paste_plain_normalises_crlf() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_text("a\r\nb\rc").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert_eq!(doc.block_count(), 3, "CRLF and CR must split like LF");
}
#[test]
fn editor_right_click_opens_default_context_menu() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let before = tree.active_overlays().len();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(50.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
tree.active_overlays().len() > before,
"right-click on an editor with default_context_menu enabled must open an overlay \
(before: {}, after: {})",
before,
tree.active_overlays().len()
);
}
#[test]
fn editor_right_click_suppressed_when_default_context_menu_disabled() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("alpha bravo").unwrap();
let editor = RichTextEditor::editor(doc).default_context_menu(false);
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let before = tree.active_overlays().len();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(50.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
tree.active_overlays().len(),
before,
"default_context_menu(false) must not show any overlay on right-click"
);
}
#[test]
fn editor_context_menu_copy_item_copies_selection_to_clipboard() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(30.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.layout(SizeProposal::exact(400.0, 300.0));
let update = tree.sync_accessibility();
let copy_node_id = update
.nodes
.iter()
.find(|(_, n)| {
n.role() == teksilo_core::accesskit::Role::MenuItem && n.label() == Some("Copy")
})
.map(|(id, _)| *id)
.expect("Copy menu item must appear in the a11y tree after right-click");
let copy_widget_id = teksilo_core::accessibility::node_id_to_widget_id_maybe(copy_node_id)
.expect("MenuItem NodeId maps back to a concrete WidgetId");
let bounds = tree.bounds(copy_widget_id);
assert!(
bounds.width > 0.0 && bounds.height > 0.0,
"Copy menu item must have non-zero bounds after overlay layout — got {:?}",
bounds
);
tree.click(copy_widget_id);
tick_past_debounce(&mut tree);
assert_eq!(
clipboard.get_text().unwrap_or_default(),
"Hello world",
"clicking Copy in the context menu must write the selected plain text to the clipboard"
);
assert!(
clipboard.has_html(),
"clicking Copy must also write the HTML payload via DocumentFragment::to_html"
);
}
#[test]
fn editor_context_menu_paste_unformatted_item_strips_formatting() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("before ").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html("<b>BOLD</b>", "BOLD").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(30.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.layout(SizeProposal::exact(400.0, 300.0));
let update = tree.sync_accessibility();
let pu_node_id = update
.nodes
.iter()
.find(|(_, n)| {
n.role() == teksilo_core::accesskit::Role::MenuItem
&& n.label() == Some("Paste Unformatted")
})
.map(|(id, _)| *id)
.expect("Paste Unformatted menu item must appear");
let pu_widget_id = teksilo_core::accessibility::node_id_to_widget_id_maybe(pu_node_id)
.expect("MenuItem NodeId maps back to WidgetId");
tree.click(pu_widget_id);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("BOLD"),
"plain text must be inserted verbatim, got {:?}",
plain
);
let b_pos = plain.find("BOLD").expect("BOLD substring");
let probe = doc.cursor();
probe.set_position(b_pos, teksilo_text::text_document::MoveMode::MoveAnchor);
let fmt = probe.char_format().unwrap_or_default();
assert!(
!matches!(fmt.font_bold, Some(true)),
"Paste Unformatted must not apply bold; got font_bold = {:?}",
fmt.font_bold
);
}
#[test]
fn editor_context_menu_copy_item_disabled_without_selection() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(30.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let update = tree.sync_accessibility();
let copy_node = update
.nodes
.iter()
.find(|(_, n)| {
n.role() == teksilo_core::accesskit::Role::MenuItem && n.label() == Some("Copy")
})
.expect("Copy menu item must appear in the a11y tree");
assert!(
copy_node.1.is_disabled(),
"Copy must be disabled when there is no selection"
);
}
#[test]
fn read_only_editor_context_menu_shape_hides_cut_and_paste() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("quiet").unwrap();
let editor = RichTextEditor::read_only(doc);
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(20.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let update = tree.sync_accessibility();
let labels: Vec<Option<&str>> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == teksilo_core::accesskit::Role::MenuItem)
.map(|(_, n)| n.label())
.collect();
assert!(
labels.contains(&Some("Copy")),
"read-only menu must include Copy, got labels {:?}",
labels
);
assert!(
labels.contains(&Some("Select All")),
"read-only menu must include Select All, got labels {:?}",
labels
);
assert!(
!labels.contains(&Some("Cut")),
"read-only menu must NOT include Cut, got labels {:?}",
labels
);
assert!(
!labels.contains(&Some("Paste")),
"read-only menu must NOT include Paste, got labels {:?}",
labels
);
assert!(
!labels.contains(&Some("Paste Unformatted")),
"read-only menu must NOT include Paste Unformatted, got labels {:?}",
labels
);
}
#[test]
fn editor_context_menu_slot_replaces_default_entirely() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("content").unwrap();
let editor = RichTextEditor::editor(doc).context_menu(|_pos, _ctx| {
Some(Box::new(
crate::menu_list::MenuList::new()
.item(
crate::menu_item::MenuItem::new(lit!("Custom Action A")).on_activate_fn(|_| ()),
)
.item(
crate::menu_item::MenuItem::new(lit!("Custom Action B")).on_activate_fn(|_| ()),
),
))
});
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(20.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let update = tree.sync_accessibility();
let labels: Vec<Option<&str>> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == teksilo_core::accesskit::Role::MenuItem)
.map(|(_, n)| n.label())
.collect();
assert!(
labels.contains(&Some("Custom Action A")),
"custom factory's items must appear, got {:?}",
labels
);
assert!(
!labels.contains(&Some("Copy")),
"default Copy item must NOT appear when a custom factory is installed, got {:?}",
labels
);
}
#[test]
fn editor_right_click_does_not_collapse_selection() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let has_sel = editor.has_selection();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
assert!(has_sel.get(), "Ctrl+A must flip has_selection");
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(30.0, 10.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
has_sel.get(),
"right-click must preserve the existing selection"
);
}
#[test]
fn editor_right_click_outside_selection_repositions_caret() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let has_sel = editor.has_selection();
let caret = editor.cursor_position_signal();
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
focus_editor(&mut tree, id);
handle.select_range(6, 11);
assert!(has_sel.get(), "precondition: a selection exists");
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(1.0, 8.0),
button: PointerButton::Secondary,
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
!has_sel.get(),
"right-click outside the selection must collapse it"
);
assert!(
caret.get() < 6,
"caret must move to the click point (got {})",
caret.get()
);
}
#[test]
fn editor_offset_at_point_hit_tests_window_coordinates() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let len = "Hello world".chars().count();
let left = handle.offset_at_point(Point::new(2.0, 8.0));
let right = handle.offset_at_point(Point::new(60.0, 8.0));
assert!(
matches!(left, Some(n) if n <= len),
"a left-side click resolves to an in-bounds offset (got {left:?})"
);
assert!(
matches!(right, Some(n) if n <= len),
"a right-side click resolves to an in-bounds offset (got {right:?})"
);
if let (Some(l), Some(r)) = (left, right) {
assert!(r >= l, "offset grows left→right ({l} → {r})");
}
}
#[test]
fn editor_replace_range_rewrites_the_span_and_leaves_the_caret_after_it() {
let doc = TextDocument::new();
doc.set_plain_text("Hello wrld today").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let caret = editor.cursor_position_signal();
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.replace_range(6, 10, "world");
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"Hello world today",
"the span is rewritten, the surrounding text untouched"
);
assert_eq!(
caret.get(),
11,
"the caret sits just after the inserted text"
);
handle.undo();
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"Hello wrld today",
"one undo restores the original span"
);
}
#[test]
fn editor_selection_reports_both_ends_from_one_read() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.select_range(3, 3);
assert_eq!(
handle.selection(),
(3, 3),
"no selection ⇒ anchor == position"
);
handle.select_range(6, 11);
assert_eq!(handle.selection(), (6, 11));
handle.select_range(11, 6);
assert_eq!(handle.selection(), (11, 6), "anchor may trail the caret");
}
#[test]
fn editor_replace_range_uses_char_offsets_not_bytes() {
let doc = TextDocument::new();
doc.set_plain_text("café wrld").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _cb = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.replace_range(5, 9, "world");
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"café world",
"char offsets survive a multi-byte character earlier in the line"
);
}
#[test]
fn read_only_editor_arrow_keys_survive_default_context_menu() {
use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::read_only(doc);
let caret = editor.cursor_position_signal();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(1.0, 8.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let focused = tree.focused();
assert!(
focused.is_some_and(|f| f == id || tree.is_descendant_of(f, id)),
"click must focus editor (focus={:?})",
focused,
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::End,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
caret.get(),
"Hello world".chars().count(),
"End must move caret to block end even with default context menu installed"
);
}
#[test]
fn read_only_editor_paste_unformatted_rejected_by_command_filter() {
let doc = TextDocument::new();
doc.set_plain_text("immutable").unwrap();
let editor = RichTextEditor::read_only(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_text("new content").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::V,
teksilo_core::event::Modifiers::CTRL | teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"immutable",
"read-only editor must reject Ctrl+Shift+V"
);
}
#[test]
fn accessibility_emits_paragraph_and_text_run_children() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let has_paragraph = update
.nodes
.iter()
.any(|(_, n)| n.role() == Role::Paragraph);
let has_text_run = update.nodes.iter().any(|(_, n)| n.role() == Role::TextRun);
assert!(
has_paragraph,
"editor must emit at least one Paragraph child"
);
assert!(has_text_run, "editor must emit at least one TextRun child");
}
fn reported_caret(
update: &teksilo_core::accesskit::TreeUpdate,
) -> Option<(teksilo_core::accesskit::NodeId, usize)> {
update.nodes.iter().find_map(|(_, n)| {
n.text_selection()
.map(|s| (s.focus.node, s.focus.character_index))
})
}
#[test]
fn a_caret_move_alone_rewalks_the_accessibility_selection() {
use teksilo_core::event::{Key, Modifiers};
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(&mut tree, Key::Home, Modifiers::NONE);
tree.layout(SizeProposal::exact(400.0, 300.0));
let base = reported_caret(&tree.sync_accessibility());
assert!(
base.is_some(),
"a focused editor must report a text selection"
);
press_key(&mut tree, Key::ArrowRight, Modifiers::NONE);
tree.layout(SizeProposal::exact(400.0, 300.0));
let after = reported_caret(&tree.sync_accessibility());
assert!(
after.is_some() && after != base,
"the AT selection must follow a caret-only move (before {base:?}, after \
{after:?} — equal means the walk returned the stale cached tree)"
);
}
#[test]
fn accessibility_text_run_carries_value_and_character_lengths() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("foo").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let text_run = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextRun)
.expect("at least one TextRun emitted");
assert_eq!(text_run.1.value(), Some("foo"));
assert_eq!(text_run.1.character_lengths(), &[1u8, 1, 1]);
}
#[test]
fn accessibility_multibyte_character_lengths_are_byte_counts() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("café").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let text_run = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextRun && n.value() == Some("café"))
.expect("'café' text run emitted");
assert_eq!(text_run.1.character_lengths(), &[1u8, 1, 1, 2]);
}
#[test]
fn accessibility_text_run_carries_character_positions() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let text_run = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextRun && n.value() == Some("abc"))
.expect("'abc' text run emitted");
if let Some(positions) = text_run.1.character_positions() {
assert_eq!(positions.len(), 3);
}
}
#[test]
fn accessibility_signal_driven_rebuild_on_text_edit() {
let doc = TextDocument::new();
doc.set_plain_text("before").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let first = tree.sync_accessibility();
let initial_has_before = first.nodes.iter().any(|(_, n)| n.value() == Some("before"));
assert!(initial_has_before, "initial tree must contain 'before'");
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_char(&mut tree, 'X');
tick_past_debounce(&mut tree);
tick_past_debounce(&mut tree);
let second = tree.sync_accessibility();
let has_before_x = second
.nodes
.iter()
.any(|(_, n)| n.value() == Some("beforeX"));
assert!(
has_before_x,
"a11y rebuild after text edit must surface new content"
);
}
#[test]
fn caret_char_format_uses_selection_start_when_selection_spans_formatted_runs() {
use teksilo_text::text_document::TextFormat;
let doc = TextDocument::new();
doc.set_plain_text("AB").unwrap();
let probe = doc.cursor();
probe.set_position(1, teksilo_text::text_document::MoveMode::MoveAnchor);
probe.move_position(
teksilo_text::text_document::MoveOperation::Right,
teksilo_text::text_document::MoveMode::KeepAnchor,
1,
);
let bold_fmt = TextFormat {
font_bold: Some(true),
..Default::default()
};
probe.merge_char_format(&bold_fmt).unwrap();
let editor = RichTextEditor::editor(doc);
editor.select_all();
let fmt = editor.caret_char_format();
assert!(
fmt.font_bold != Some(true),
"caret_char_format must read from selection_start (plain start) \
not selection_end (bold run); got font_bold = {:?}",
fmt.font_bold
);
}
#[test]
fn widget_formatting_api_set_bold_applies_to_selection() {
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let probe = doc.cursor();
probe.set_position(0, teksilo_text::text_document::MoveMode::MoveAnchor);
assert!(
probe.char_format().unwrap_or_default().font_bold != Some(true),
"baseline: text must not start bold"
);
editor.select_all();
editor.set_bold(true);
let probe = doc.cursor();
probe.set_position(0, teksilo_text::text_document::MoveMode::MoveAnchor);
assert_eq!(
probe.char_format().unwrap_or_default().font_bold,
Some(true),
"set_bold(true) must flip font_bold on the underlying run"
);
}
#[test]
fn widget_is_bold_reports_selection_start_format() {
let doc = TextDocument::new();
doc.set_plain_text("AB").unwrap();
let probe = doc.cursor();
probe.set_position(0, teksilo_text::text_document::MoveMode::MoveAnchor);
probe.move_position(
teksilo_text::text_document::MoveOperation::Right,
teksilo_text::text_document::MoveMode::KeepAnchor,
1,
);
probe
.merge_char_format(&teksilo_text::text_document::TextFormat {
font_bold: Some(true),
..Default::default()
})
.unwrap();
let editor = RichTextEditor::editor(doc);
editor.select_all();
assert!(
editor.is_bold(),
"is_bold must return true when the selection starts on a bold run"
);
}
#[test]
fn widget_toggle_bold_flips_current_state() {
let doc = TextDocument::new();
doc.set_plain_text("text").unwrap();
let editor = RichTextEditor::editor(doc);
editor.select_all();
assert!(!editor.is_bold(), "baseline");
editor.toggle_bold();
assert!(editor.is_bold(), "toggle_bold flips on");
editor.toggle_bold();
editor.select_all();
assert!(!editor.is_bold(), "second toggle flips off");
}
#[test]
fn widget_set_heading_level_updates_block_format() {
let doc = TextDocument::new();
doc.set_plain_text("heading").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.set_caret_position(0);
editor.set_heading_level(2);
assert_eq!(
editor.get_heading_level(),
2,
"heading_level getter must reflect the setter"
);
}
#[test]
fn widget_set_alignment_updates_block_format() {
let doc = TextDocument::new();
doc.set_plain_text("aligned").unwrap();
let editor = RichTextEditor::editor(doc);
editor.set_caret_position(0);
editor.set_alignment(teksilo_text::text_document::Alignment::Center);
assert_eq!(
editor.get_alignment(),
teksilo_text::text_document::Alignment::Center
);
}
#[test]
fn widget_is_in_table_round_trip_with_navigation() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc);
editor.insert_table(2, 3);
editor.set_caret_position(0);
assert!(
editor.is_in_table(),
"caret at position 0 with a table as the first flow element \
must be inside the table"
);
}
#[test]
fn widget_insert_list_creates_list_block() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.set_caret_position(0);
editor.insert_list(false);
let block = doc.block_at_position(0).unwrap();
assert!(
block.list().is_some(),
"insert_list(false) must convert the current block into a list item"
);
}
#[test]
fn widget_remove_from_list_leaves_a_plain_paragraph() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.set_caret_position(0);
editor.insert_list(false);
assert!(doc.block_at_position(0).unwrap().list().is_some());
editor.outdent();
assert!(
doc.block_at_position(0).unwrap().list().is_some(),
"outdent at depth 0 must leave the list intact"
);
editor.remove_from_list();
assert!(
doc.block_at_position(0).unwrap().list().is_none(),
"remove_from_list must take the block out of the list entirely"
);
}
#[test]
fn handle_remove_from_list_mirrors_the_widget() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.set_caret_position(0);
let handle = editor.handle();
handle.insert_list(true);
assert!(doc.block_at_position(0).unwrap().list().is_some());
handle.remove_from_list();
assert!(doc.block_at_position(0).unwrap().list().is_none());
handle.remove_from_list();
assert!(doc.block_at_position(0).unwrap().list().is_none());
}
#[test]
fn edit_block_collapses_several_commands_into_one_undo_entry() {
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.select_all();
let handle = editor.handle();
handle.edit_block(|| {
handle.set_bold(true);
handle.set_italic(true);
handle.set_underline(true);
});
assert!(handle.is_bold() && handle.is_italic() && handle.is_underline());
handle.undo();
assert!(
!handle.is_bold() && !handle.is_italic() && !handle.is_underline(),
"one undo must revert the whole block, not just its last command"
);
}
#[test]
fn ungrouped_commands_still_undo_one_at_a_time() {
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.select_all();
let handle = editor.handle();
handle.set_bold(true);
handle.set_italic(true);
handle.undo();
assert!(
handle.is_bold(),
"without an edit block each command is its own undo entry"
);
}
#[test]
fn edit_block_closes_even_when_the_body_returns_early() {
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.select_all();
let handle = editor.handle();
let applied = handle.edit_block(|| {
handle.set_bold(true);
if handle.is_bold() {
return true;
}
handle.set_italic(true);
false
});
assert!(applied);
handle.set_italic(true);
handle.undo();
assert!(
handle.is_bold(),
"the early return must not have left the edit block open"
);
}
#[test]
fn widget_runtime_font_size_scale_roundtrips() {
let doc = TextDocument::new();
doc.set_plain_text("size me").unwrap();
let editor = RichTextEditor::editor(doc);
editor.set_font_size_scale(2.5);
assert!((editor.get_font_size_scale() - 2.5).abs() < 1e-4);
editor.set_font_size_scale(100.0);
assert!(
editor.get_font_size_scale() <= 10.0,
"font_size_scale must clamp to the 0.1..=10.0 range"
);
}
#[test]
fn widget_runtime_typography_defaults_roundtrip_via_editor_and_handle() {
use teksilo_text::EditorTypographyDefaults;
let doc = TextDocument::new();
doc.set_plain_text("typeset me").unwrap();
let editor = RichTextEditor::editor(doc);
let defaults = EditorTypographyDefaults {
font_family: Some("Literata".into()),
line_height: 1.5,
first_line_indent: 28.0,
paragraph_spacing_before: 12.0,
paragraph_spacing_after: 6.0,
};
editor.set_typography_defaults(defaults.clone());
assert_eq!(editor.get_typography_defaults(), defaults);
let handle = editor.handle();
assert_eq!(handle.get_typography_defaults(), defaults);
let other = EditorTypographyDefaults {
font_family: None,
line_height: 2.0,
first_line_indent: 0.0,
paragraph_spacing_before: 0.0,
paragraph_spacing_after: 0.0,
};
handle.set_typography_defaults(other.clone());
assert_eq!(editor.get_typography_defaults(), other);
handle.set_font_size_scale(1.4);
assert!((editor.get_font_size_scale() - 1.4).abs() < 1e-4);
handle.set_font_size_scale(50.0);
assert!(handle.get_font_size_scale() <= 10.0);
}
#[test]
fn an_empty_paragraph_carets_where_its_first_character_will_land() {
use teksilo_text::{CursorAffinity, EditorTypographyDefaults};
const INDENT: f32 = 28.0;
let defaults = EditorTypographyDefaults {
font_family: Some("JetBrains Mono".into()),
first_line_indent: INDENT,
..EditorTypographyDefaults::default()
};
let doc = TextDocument::new();
doc.set_plain_text("\nsecond paragraph").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.set_typography_defaults(defaults);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
for _ in 0..4 {
tick_once(&mut tree);
}
let (empty_caret, second_para_y) = {
let st = state.borrow();
(
st.engine.caret_rect(0, CursorAffinity::Downstream),
st.engine.caret_rect(1, CursorAffinity::Downstream)[1],
)
};
assert!(
(empty_caret[0] - INDENT).abs() < 0.5,
"an empty paragraph's caret must sit at the first-line indent \
({INDENT}), not flush left; got x = {}",
empty_caret[0]
);
press_char(&mut tree, 'A');
tick_past_debounce(&mut tree);
let (typed_caret, typed_second_para_y) = {
let st = state.borrow();
(
st.engine.caret_rect(0, CursorAffinity::Downstream),
st.engine.caret_rect(2, CursorAffinity::Downstream)[1],
)
};
assert!(
(typed_caret[0] - empty_caret[0]).abs() < 0.5,
"the first keystroke must not move the caret: {} -> {}",
empty_caret[0],
typed_caret[0]
);
assert!(
(typed_caret[3] - empty_caret[3]).abs() < 0.5,
"the first keystroke must not resize the caret: {} -> {}",
empty_caret[3],
typed_caret[3]
);
assert!(
(typed_second_para_y - second_para_y).abs() < 0.5,
"the first keystroke must not shift the paragraphs below it: {second_para_y} \
-> {typed_second_para_y}"
);
}
#[test]
fn editor_ctrl_enter_always_inserts_block_in_table() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
editor.insert_table(2, 2);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
let block_count_before = doc.block_count();
press_key(
&mut tree,
teksilo_core::event::Key::Enter,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert!(
doc.block_count() > block_count_before,
"Ctrl+Enter in a table cell must insert a new block (count before={}, after={})",
block_count_before,
doc.block_count()
);
}
#[test]
fn editor_tab_in_list_increments_indent() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.insert_list(false);
editor.set_caret_position(0);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
let indent = state
.borrow()
.cursor
.current_list()
.map(|l| l.indent())
.unwrap_or(0);
assert!(
indent >= 1,
"Tab at list-item start must increase list indent (got {})",
indent
);
}
#[test]
fn editor_shift_tab_in_list_decrements_indent() {
use teksilo_text::text_document::ListFormat;
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.insert_list(false);
editor.set_caret_position(0);
let _ = state.borrow().cursor.set_current_list_format(&ListFormat {
indent: Some(2),
..Default::default()
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
let indent = state
.borrow()
.cursor
.current_list()
.map(|l| l.indent())
.unwrap_or(0);
assert_eq!(
indent, 1,
"Shift+Tab must decrement list indent from 2 to 1 (got {})",
indent
);
}
#[test]
fn editor_tab_in_list_indents_from_mid_block_caret() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.insert_list(false);
editor.set_caret_position(2);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
let indent = state
.borrow()
.cursor
.current_list()
.map(|l| l.indent())
.unwrap_or(0);
assert!(
indent >= 1,
"Tab mid-block inside a list must still indent (got {})",
indent
);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"item",
"Tab in a list must not insert a literal \\t"
);
}
#[test]
fn editor_shift_tab_in_list_dedents_from_mid_block_caret() {
use teksilo_text::text_document::ListFormat;
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.insert_list(false);
editor.set_caret_position(0);
let _ = state.borrow().cursor.set_current_list_format(&ListFormat {
indent: Some(2),
..Default::default()
});
editor.set_caret_position(2);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
let indent = state
.borrow()
.cursor
.current_list()
.map(|l| l.indent())
.unwrap_or(0);
assert_eq!(
indent, 1,
"Shift+Tab mid-block must decrement list indent from 2 to 1 (got {})",
indent
);
}
#[test]
fn editor_tab_in_multi_item_list_indents_only_current_item() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.select_all();
editor.insert_list(false);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
state
.borrow()
.cursor
.set_position(2, teksilo_text::text_document::MoveMode::MoveAnchor);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
let a_list_id = doc
.block_at_position(0)
.and_then(|b| b.list())
.map(|l| l.id());
let b_list_id = doc
.block_at_position(2)
.and_then(|b| b.list())
.map(|l| l.id());
let c_list_id = doc
.block_at_position(4)
.and_then(|b| b.list())
.map(|l| l.id());
let a_indent = doc
.block_at_position(0)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(255);
let b_indent = doc
.block_at_position(2)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(255);
let c_indent = doc
.block_at_position(4)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(255);
assert_eq!(a_indent, 0, "sibling 'a' must stay at indent 0");
assert_eq!(b_indent, 1, "current item 'b' must move to indent 1");
assert_eq!(c_indent, 0, "sibling 'c' must stay at indent 0");
assert!(a_list_id.is_some() && b_list_id.is_some() && c_list_id.is_some());
assert_eq!(
a_list_id, c_list_id,
"'a' and 'c' must share the parent list"
);
assert_ne!(a_list_id, b_list_id, "'b' must be in its own nested list");
}
#[test]
fn editor_shift_tab_in_multi_item_list_dedents_only_current_item() {
use teksilo_text::text_document::ListFormat;
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.select_all();
editor.insert_list(false);
let _ = state.borrow().cursor.set_current_list_format(&ListFormat {
indent: Some(2),
..Default::default()
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
state
.borrow()
.cursor
.set_position(2, teksilo_text::text_document::MoveMode::MoveAnchor);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
let a_indent = doc
.block_at_position(0)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(99);
let b_indent = doc
.block_at_position(2)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(99);
let c_indent = doc
.block_at_position(4)
.and_then(|b| b.list())
.map(|l| l.indent())
.unwrap_or(99);
assert_eq!(a_indent, 2, "sibling 'a' must stay at indent 2");
assert_eq!(b_indent, 1, "current item 'b' must dedent to indent 1");
assert_eq!(c_indent, 2, "sibling 'c' must stay at indent 2");
}
#[test]
fn editor_backspace_at_list_start_dedents_or_exits() {
let doc = TextDocument::new();
doc.set_plain_text("item").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.insert_list(false);
editor.set_caret_position(0);
editor.apply_block_format(teksilo_text::text_document::BlockFormat {
indent: Some(1),
..Default::default()
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
let indent = state
.borrow()
.cursor
.block_format()
.ok()
.and_then(|f| f.indent)
.unwrap_or(0);
assert_eq!(
indent, 0,
"first Backspace dedents from 1 to 0 (got {})",
indent
);
press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
let in_list = doc.block_at_position(0).and_then(|b| b.list()).is_some();
assert!(
!in_list,
"second Backspace at indent 0 must remove block from list"
);
}
#[test]
fn editor_tab_in_quote_increases_depth() {
let doc = TextDocument::new();
doc.set_markdown("> Quoted line.\n")
.unwrap()
.wait()
.unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.set_caret_position(0);
assert_eq!(state.borrow().cursor.blockquote_depth_at_cursor(), 1);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert_eq!(
state.borrow().cursor.blockquote_depth_at_cursor(),
2,
"Tab inside a depth-1 quote must produce depth-2"
);
}
#[test]
fn editor_shift_tab_at_depth_1_unwraps_to_plain() {
let doc = TextDocument::new();
doc.set_markdown("> Quoted line.\n")
.unwrap()
.wait()
.unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.set_caret_position(0);
assert_eq!(state.borrow().cursor.blockquote_depth_at_cursor(), 1);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Tab,
teksilo_core::event::Modifiers::SHIFT,
);
tick_past_debounce(&mut tree);
assert_eq!(
state.borrow().cursor.blockquote_depth_at_cursor(),
0,
"Shift+Tab at depth 1 must unwrap to plain paragraph"
);
}
#[test]
fn editor_backspace_at_quote_first_block_unwraps() {
let doc = TextDocument::new();
doc.set_markdown("> Quoted line.\n")
.unwrap()
.wait()
.unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.set_caret_position(0);
assert!(state.borrow().cursor.is_in_blockquote());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert!(
!state.borrow().cursor.is_in_blockquote(),
"Backspace at the first block of a quote must unwrap the block"
);
}
#[test]
fn editor_delete_at_last_pos_of_last_quoted_block_unwraps() {
let doc = TextDocument::new();
doc.set_markdown("> Quoted.\n").unwrap().wait().unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.set_caret_position(0);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert!(state.borrow().cursor.is_in_blockquote());
assert!(state.borrow().cursor.at_block_end());
assert!(state.borrow().cursor.is_last_block_in_current_frame());
press_key(
&mut tree,
teksilo_core::event::Key::Delete,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert!(
!state.borrow().cursor.is_in_blockquote(),
"Delete at the end of the last quoted block must unwrap, not cross-frame merge"
);
}
#[test]
fn editor_toggle_blockquote_wraps_then_unwraps() {
let doc = TextDocument::new();
doc.set_plain_text("Hello.").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
editor.set_caret_position(0);
assert!(!state.borrow().cursor.is_in_blockquote());
editor.toggle_blockquote();
assert!(
state.borrow().cursor.is_in_blockquote(),
"first toggle must wrap"
);
editor.toggle_blockquote();
assert!(
!state.borrow().cursor.is_in_blockquote(),
"second toggle must unwrap"
);
}
#[test]
fn editor_enter_then_enter_at_end_of_quote_exits_after_not_before() {
let doc = TextDocument::new();
doc.set_markdown("> A\n").unwrap().wait().unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
state
.borrow()
.cursor
.set_position(1, teksilo_text::text_document::MoveMode::MoveAnchor);
press_key(
&mut tree,
teksilo_core::event::Key::Enter,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert!(
state.borrow().cursor.is_in_blockquote(),
"after 1st Enter the cursor must still be inside the quote (new empty block)"
);
assert!(
state.borrow().cursor.current_block_is_empty(),
"after 1st Enter the cursor's block must be the new empty one"
);
press_key(
&mut tree,
teksilo_core::event::Key::Enter,
teksilo_core::event::Modifiers::NONE,
);
tick_past_debounce(&mut tree);
assert!(
!state.borrow().cursor.is_in_blockquote(),
"after 2nd Enter the cursor must be outside the quote (depth dropped)"
);
let md = doc.to_markdown().unwrap();
let a_idx = md
.find("> A")
.unwrap_or_else(|| panic!("`> A` missing after Enter-Enter; got: {md:?}"));
let before_a = &md[..a_idx];
assert!(
!before_a.contains('>'),
"no quoted line must appear BEFORE `> A` after exiting the quote; got: {md:?}"
);
}
#[test]
fn link_click_callback_installs_without_panicking() {
use std::cell::RefCell;
use std::rc::Rc;
let doc = TextDocument::new();
doc.set_html(r#"<p><a href="https://example.com/x">link</a></p>"#)
.unwrap();
let seen = Rc::new(RefCell::new(Vec::<String>::new()));
let seen_clone = seen.clone();
let editor = RichTextEditor::editor(doc).on_link_activated(move |href, _ctx| {
seen_clone.borrow_mut().push(href.to_string());
});
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
position: Point::new(5.0, 10.0),
button: teksilo_core::event::PointerButton::Primary,
modifiers: teksilo_core::event::Modifiers::NONE,
});
let _ = seen;
}
#[test]
fn format_version_bumps_on_format_only_edits() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let fmt_ver = editor.format_version();
let baseline = fmt_ver.get();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::B,
teksilo_core::event::Modifiers::CTRL,
);
tick_past_debounce(&mut tree);
assert!(
fmt_ver.get() > baseline,
"format_version must bump after a format-only edit (baseline={}, now={})",
baseline,
fmt_ver.get()
);
}
#[test]
fn no_min_max_lines_preserves_greedy_sizing() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let bounds = tree.bounds(id);
assert!((bounds.width - 400.0).abs() < 0.5);
assert!((bounds.height - 300.0).abs() < 0.5);
}
#[test]
fn min_lines_enforces_intrinsic_height_on_empty_doc() {
use crate::styles::recipe_text_input_style::TEXT_FIELD_PADDING_VERTICAL;
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc).min_lines(3);
let line_h = {
let st = editor.state_handle();
let st = st.borrow();
st.engine.default_line_height()
};
assert!(
line_h > 0.0,
"default_line_height must be non-zero for the embedded font"
);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::with_width(400.0));
let bounds = tree.bounds(id);
let expected = 3.0 * line_h + 2.0 * TEXT_FIELD_PADDING_VERTICAL;
assert!(
(bounds.height - expected).abs() < 1.0,
"expected ~{}px (3 × {:.2} body + 2 × {:.1} chrome), got {:.2}",
expected,
line_h,
TEXT_FIELD_PADDING_VERTICAL,
bounds.height
);
}
#[test]
fn max_lines_caps_intrinsic_height_below_proposal() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc).max_lines(2);
let line_h = {
let st = editor.state_handle();
let st = st.borrow();
st.engine.default_line_height()
};
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::with_width(400.0));
let bounds = tree.bounds(id);
let expected = 2.0 * line_h;
assert!(
bounds.height <= expected + 0.5,
"max_lines(2) must cap at 2 × line_height ({:.2}), got {:.2}",
expected,
bounds.height
);
}
#[test]
fn min_and_max_lines_clamp_growth_within_window() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc).min_lines(1).max_lines(4);
let line_h = {
let st = editor.state_handle();
let st = st.borrow();
st.engine.default_line_height()
};
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::with_width(400.0));
let bounds = tree.bounds(id);
assert!(
bounds.height >= line_h - 0.5 && bounds.height <= 4.0 * line_h + 0.5,
"intrinsic height must land in [1, 4] × line_height, got {:.2}",
bounds.height
);
}
#[test]
fn editor_chrome_padding_is_included_in_wrapper_bounds() {
use crate::styles::recipe_text_input_style::{
TEXT_FIELD_PADDING_HORIZONTAL, TEXT_FIELD_PADDING_VERTICAL,
};
let doc_a = TextDocument::new();
doc_a.set_plain_text("hi").unwrap();
let read_only = RichTextEditor::read_only(doc_a);
let mut tree_ro = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id_ro = tree_ro.add(read_only);
tree_ro.layout(SizeProposal::exact(400.0, 60.0));
let b_ro = tree_ro.bounds(id_ro);
assert!(
(b_ro.width - 400.0).abs() < 0.5,
"read-only viewer fills the proposal width without chrome inset, got {}",
b_ro.width,
);
let doc_b = TextDocument::new();
doc_b.set_plain_text("hi").unwrap();
let editor = RichTextEditor::editor(doc_b);
let mut tree_ed = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id_ed = tree_ed.add(editor);
tree_ed.layout(SizeProposal::exact(400.0, 60.0));
let b_ed = tree_ed.bounds(id_ed);
let body_id = tree_ed.first_focusable_descendant(id_ed);
let _ = body_id;
assert!(
(b_ed.width - 400.0).abs() < 0.5,
"editable editor fills the proposal width, got {}",
b_ed.width,
);
let _ = TEXT_FIELD_PADDING_HORIZONTAL;
let _ = TEXT_FIELD_PADDING_VERTICAL;
}
#[test]
fn editor_style_override_installs_custom_chrome() {
use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{RichTextEditorStyle, RichTextEditorStyleConfig};
use teksilo_core::widget_id::WidgetId;
#[derive(Default)]
struct PassthroughStyle;
impl RichTextEditorStyle for PassthroughStyle {
fn make_body(&self, cfg: &RichTextEditorStyleConfig, _ctx: &mut BuildContext) -> WidgetId {
cfg.viewport
}
}
let doc = TextDocument::new();
doc.set_plain_text("hi").unwrap();
let editor = RichTextEditor::editor(doc).style(PassthroughStyle);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 60.0));
let b = tree.bounds(id);
assert!((b.width - 400.0).abs() < 0.5);
assert!((b.height - 60.0).abs() < 0.5);
}
#[test]
fn color_overrides_store_reactive_props() {
use teksilo_tokens::{SurfaceRole, TextRole};
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc)
.text_color(TextRole::Primary)
.caret_color(TextRole::Accent)
.selection_color(TextRole::Accent)
.background(SurfaceRole::Content);
let state = editor.state_handle();
let st = state.borrow();
assert!(st.text_color_prop.is_some(), "text_color stored as a prop");
assert!(
st.caret_color_prop.is_some(),
"caret_color stored as a prop"
);
assert!(
st.selection_color_prop.is_some(),
"selection_color stored as a prop"
);
assert!(st.background_prop.is_some(), "background stored as a prop");
}
#[test]
fn editor_wrapper_is_generic_container_in_a11y_tree() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 60.0));
let info = tree.accessibility_node(id);
assert_eq!(
info.role(),
Role::GenericContainer,
"wrapper RichTextEditor must emit GenericContainer in the AT tree (got {:?})",
info.role(),
);
let update = tree.sync_accessibility();
let has_input = update
.nodes
.iter()
.any(|(_, n)| n.role() == Role::MultilineTextInput);
assert!(
has_input,
"the inner body must still emit Role::MultilineTextInput",
);
}
fn with_on_change_probe(editor: RichTextEditor) -> (RichTextEditor, Rc<Cell<u32>>) {
let counter = Rc::new(Cell::new(0u32));
let probe = counter.clone();
(
editor.on_change(move || probe.set(probe.get() + 1)),
counter,
)
}
#[test]
fn editor_on_change_fires_for_plain_typing() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_char(&mut tree, 'x');
for _ in 0..3 {
tick_once(&mut tree);
}
assert!(
fired.get() > 0,
"typing a character must fire on_change (fired {} times)",
fired.get()
);
}
#[test]
fn editor_on_change_fires_on_enter_paragraph_split() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::Enter,
teksilo_core::event::Modifiers::NONE,
);
for _ in 0..3 {
tick_once(&mut tree);
}
assert!(
fired.get() > 0,
"pressing Enter (paragraph split) must fire on_change — a structural \
edit is still a user edit (fired {} times)",
fired.get()
);
}
#[test]
fn editor_on_change_fires_on_backspace_paragraph_merge() {
let doc = TextDocument::new();
doc.set_plain_text("first\nsecond").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
press_key(
&mut tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::NONE,
);
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0); press_key(
&mut tree,
teksilo_core::event::Key::Backspace,
teksilo_core::event::Modifiers::NONE,
);
for _ in 0..3 {
tick_once(&mut tree);
}
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("firstsecond"),
"precondition: Backspace at block start must merge the paragraphs, got {plain:?}"
);
assert!(
fired.get() > 0,
"a backspace that merges two paragraphs must fire on_change (fired {} times)",
fired.get()
);
}
#[test]
fn editor_on_change_fires_on_accesskit_set_value_replace() {
use teksilo_core::accessibility::widget_id_to_node_id;
use teksilo_core::accesskit::{Action, ActionData};
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0);
let mut ops = teksilo_core::window::NoopWindowOps;
let resolved = tree.dispatch_access_action(
widget_id_to_node_id(id),
Action::SetValue,
Some(ActionData::Value("alpha\nbeta".into())),
&mut ops,
);
assert!(resolved, "SetValue must resolve a live target widget");
for _ in 0..3 {
tick_once(&mut tree);
}
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("alpha") && plain.contains("beta") && !plain.contains("first"),
"SetValue must replace the whole document, got {plain:?}"
);
assert!(
fired.get() > 0,
"an assistive-tech SetValue edit must fire on_change (fired {} times)",
fired.get()
);
}
#[test]
fn editor_on_change_stays_silent_for_programmatic_load() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0);
doc.set_plain_text("one\ntwo\nthree").unwrap();
for _ in 0..3 {
tick_once(&mut tree);
}
assert_eq!(
fired.get(),
0,
"a programmatic load must NOT fire on_change, even though it changes \
the block count"
);
}
#[test]
fn editor_on_change_stays_silent_during_ime_composition_then_fires_once_on_commit() {
use teksilo_core::event::WidgetEvent;
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "n".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"n",
"the preedit must still render live"
);
assert_eq!(
fired.get(),
0,
"an intermediate IME composition step must not fire on_change"
);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "ni".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
assert_eq!(
fired.get(),
0,
"a second intermediate composition step must still not fire on_change"
);
tree.dispatch_event(WidgetEvent::ImeCommit {
text: "你".to_string(),
});
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "你");
assert_eq!(
fired.get(),
1,
"the commit must fire on_change exactly once for the settled result"
);
}
mod affinity_tests {
use super::*;
use teksilo_text::CursorAffinity;
const WRAPPING_TEXT: &str = "The quick brown fox jumps over the lazy dog. \
A long paragraph that absolutely positively must wrap across \
multiple visual lines so the soft-wrap-boundary affinity \
tests have something concrete to assert against.";
struct WrappedEditor {
tree: WidgetTree,
state: super::super::SharedState,
boundary_pos: usize,
line_1_window_y: f32,
line_2_window_y: f32,
body_left_edge_x: f32,
body_right_edge_x: f32,
}
fn make_wrapped_editor() -> WrappedEditor {
let doc = TextDocument::new();
doc.set_plain_text(WRAPPING_TEXT).unwrap();
let editor = RichTextEditor::editor(doc);
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(280.0, 400.0));
focus_editor(&mut tree, id);
for _ in 0..4 {
tick_once(&mut tree);
}
let (boundary_pos, line_1_widget_y, line_2_widget_y, viewport_origin, viewport_width) = {
let st = state.borrow();
let mut boundary: Option<(usize, f32, f32)> = None;
for pos in 1..WRAPPING_TEXT.len() {
let d = st.engine.caret_rect(pos, CursorAffinity::Downstream);
let u = st.engine.caret_rect(pos, CursorAffinity::Upstream);
if (u[1] - d[1]).abs() > 1.0 {
boundary = Some((pos, d[1], u[1]));
break;
}
}
let (bp, d_y, u_y) = boundary
.expect("doc must wrap at the test viewport width — widen text or narrow viewport");
(bp, d_y, u_y, st.viewport_origin, st.viewport_width)
};
let line_1_window_y = line_1_widget_y + viewport_origin.y;
let line_2_window_y = line_2_widget_y + viewport_origin.y;
let body_left_edge_x = viewport_origin.x + 2.0;
let body_right_edge_x = viewport_origin.x + viewport_width - 2.0;
WrappedEditor {
tree,
state,
boundary_pos,
line_1_window_y,
line_2_window_y,
body_left_edge_x,
body_right_edge_x,
}
}
#[test]
fn fresh_editor_has_downstream_affinity_by_default() {
let we = make_wrapped_editor();
assert_eq!(
we.state.borrow().cursor_affinity,
CursorAffinity::Downstream,
"freshly built editor must default to Downstream affinity"
);
}
#[test]
fn click_at_start_of_wrapped_line_sets_upstream_affinity() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
let st = we.state.borrow();
assert_eq!(
st.cursor.position(),
we.boundary_pos,
"click at start of line 2 should land at the wrap-boundary position"
);
assert_eq!(
st.cursor_affinity,
CursorAffinity::Upstream,
"click at start of line 2 should produce Upstream affinity (caret on line 2's left edge)"
);
}
#[test]
fn click_at_end_of_wrapped_line_sets_downstream_affinity() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
synth_pointer_up(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
let st = we.state.borrow();
assert_eq!(
st.cursor.position(),
we.boundary_pos,
"click past line-1 end should land at the wrap-boundary position"
);
assert_eq!(
st.cursor_affinity,
CursorAffinity::Downstream,
"click past line-1 end should produce Downstream affinity (caret on line 1's right end)"
);
}
#[test]
fn typing_a_character_resets_affinity_to_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
assert_eq!(we.state.borrow().cursor_affinity, CursorAffinity::Upstream);
press_char(&mut we.tree, 'x');
tick_past_debounce(&mut we.tree);
assert_eq!(
we.state.borrow().cursor_affinity,
CursorAffinity::Downstream,
"edit must reset affinity to Downstream — the inserted character makes the previous upstream placement meaningless"
);
}
#[test]
fn left_arrow_resets_affinity_to_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
assert_eq!(we.state.borrow().cursor_affinity, CursorAffinity::Upstream);
press_key(
&mut we.tree,
teksilo_core::event::Key::ArrowLeft,
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(
we.state.borrow().cursor_affinity,
CursorAffinity::Downstream,
"Left arrow must reset affinity to Downstream"
);
}
#[test]
fn right_arrow_resets_affinity_to_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
synth_pointer_up(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
press_key(
&mut we.tree,
teksilo_core::event::Key::ArrowRight,
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(
we.state.borrow().cursor_affinity,
CursorAffinity::Downstream,
"Right arrow must reset affinity to Downstream"
);
}
#[test]
fn home_from_end_of_line_1_lands_at_line_1_start_with_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
synth_pointer_up(&mut we.tree, we.body_right_edge_x, we.line_1_window_y);
press_key(
&mut we.tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::NONE,
);
let st = we.state.borrow();
assert_eq!(st.cursor.position(), 0, "Home from line 1 → position 0");
assert_eq!(
st.cursor_affinity,
CursorAffinity::Downstream,
"block start is not a wrap continuation; Home produces Downstream"
);
}
#[test]
fn home_from_start_of_line_2_lands_at_line_2_start_with_upstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
assert_eq!(we.state.borrow().cursor_affinity, CursorAffinity::Upstream);
press_key(
&mut we.tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::NONE,
);
let st = we.state.borrow();
assert_eq!(
st.cursor.position(),
we.boundary_pos,
"Home from line 2 should stay at line-2's start (= wrap boundary)"
);
assert_eq!(
st.cursor_affinity,
CursorAffinity::Upstream,
"Home from upstream-line-2-start must preserve Upstream — the caret stays on line 2"
);
}
#[test]
fn end_from_start_of_line_2_lands_at_line_2_end() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
let pos_before = we.state.borrow().cursor.position();
press_key(
&mut we.tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::NONE,
);
let st = we.state.borrow();
assert!(
st.cursor.position() > pos_before,
"End from line 2 should advance the caret past line 2's start"
);
}
#[test]
fn ctrl_home_resets_affinity_to_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
synth_pointer_up(&mut we.tree, we.body_left_edge_x, we.line_2_window_y);
assert_eq!(we.state.borrow().cursor_affinity, CursorAffinity::Upstream);
press_key(
&mut we.tree,
teksilo_core::event::Key::Home,
teksilo_core::event::Modifiers::CTRL,
);
let st = we.state.borrow();
assert_eq!(st.cursor.position(), 0, "Ctrl+Home goes to document start");
assert_eq!(
st.cursor_affinity,
CursorAffinity::Downstream,
"Ctrl+Home is a logical jump — resets affinity to Downstream"
);
}
#[test]
fn set_caret_position_resets_affinity_to_downstream() {
let doc = TextDocument::new();
doc.set_plain_text(WRAPPING_TEXT).unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(280.0, 400.0));
focus_editor(&mut tree, id);
for _ in 0..4 {
tick_once(&mut tree);
}
{
let mut st = state.borrow_mut();
st.cursor_affinity = CursorAffinity::Upstream;
}
assert_eq!(state.borrow().cursor_affinity, CursorAffinity::Upstream);
{
let mut st = state.borrow_mut();
st.cursor
.set_position(0, teksilo_text::text_document::MoveMode::MoveAnchor);
st.cursor_affinity = CursorAffinity::Downstream;
}
assert_eq!(
state.borrow().cursor_affinity,
CursorAffinity::Downstream,
"the set_caret_position code path collapses affinity to Downstream"
);
}
#[test]
fn click_at_middle_of_line_1_is_downstream() {
let mut we = make_wrapped_editor();
synth_pointer_down(&mut we.tree, 50.0, we.line_1_window_y);
synth_pointer_up(&mut we.tree, 50.0, we.line_1_window_y);
let st = we.state.borrow();
assert!(st.cursor.position() < we.boundary_pos);
assert_eq!(
st.cursor_affinity,
CursorAffinity::Downstream,
"non-boundary clicks must produce Downstream affinity"
);
}
#[test]
fn paint_only_highlight_recolors_without_full_layout() {
use std::sync::Arc;
use teksilo_text::text_document::{
Color, HighlightContext, HighlightFormat, SyntaxHighlighter,
};
struct BgHighlighter;
impl SyntaxHighlighter for BgHighlighter {
fn highlight_block(&self, text: &str, ctx: &mut HighlightContext) {
let n = text.chars().count();
if n > 0 {
ctx.set_format(
0,
n,
HighlightFormat {
background_color: Some(Color::rgba(255, 214, 0, 150)),
..Default::default()
},
);
}
}
}
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::read_only(doc.clone()).show_highlights(true);
let state = editor.state_handle();
state.borrow_mut().drain_events();
{
let mut st = state.borrow_mut();
st.needs_full_layout = false;
st.pending_recolor = false;
}
doc.set_syntax_highlighter(Some(Arc::new(BgHighlighter)));
let (had, single) = state.borrow_mut().drain_events();
assert!(had, "attaching a highlighter should produce an event");
let st = state.borrow();
assert!(
st.pending_recolor,
"paint-only change must request a recolor"
);
assert!(
!st.needs_full_layout,
"paint-only change must NOT trigger a full relayout"
);
assert!(single.is_none());
}
#[test]
fn metric_highlight_triggers_full_layout() {
use std::sync::Arc;
use teksilo_text::text_document::{HighlightContext, HighlightFormat, SyntaxHighlighter};
struct BoldHighlighter;
impl SyntaxHighlighter for BoldHighlighter {
fn highlight_block(&self, text: &str, ctx: &mut HighlightContext) {
let n = text.chars().count();
if n > 0 {
ctx.set_format(
0,
n,
HighlightFormat {
font_bold: Some(true),
..Default::default()
},
);
}
}
}
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::read_only(doc.clone()).show_highlights(true);
let state = editor.state_handle();
state.borrow_mut().drain_events();
{
let mut st = state.borrow_mut();
st.needs_full_layout = false;
st.pending_recolor = false;
}
doc.set_syntax_highlighter(Some(Arc::new(BoldHighlighter)));
state.borrow_mut().drain_events();
let st = state.borrow();
assert!(
st.needs_full_layout,
"metric highlight must trigger a full relayout"
);
assert!(
!st.pending_recolor,
"metric highlight is not a recolor-only change"
);
}
#[test]
fn bare_view_ignores_paint_only_highlight() {
use std::sync::Arc;
use teksilo_text::text_document::{
Color, HighlightContext, HighlightFormat, SyntaxHighlighter,
};
struct BgHighlighter;
impl SyntaxHighlighter for BgHighlighter {
fn highlight_block(&self, text: &str, ctx: &mut HighlightContext) {
let n = text.chars().count();
if n > 0 {
ctx.set_format(
0,
n,
HighlightFormat {
background_color: Some(Color::rgba(255, 214, 0, 150)),
..Default::default()
},
);
}
}
}
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let viewer = RichTextEditor::read_only(doc.clone());
let state = viewer.state_handle();
state.borrow_mut().drain_events();
{
let mut st = state.borrow_mut();
st.needs_full_layout = false;
st.pending_recolor = false;
}
doc.set_syntax_highlighter(Some(Arc::new(BgHighlighter)));
state.borrow_mut().drain_events();
let st = state.borrow();
assert!(
!st.pending_recolor,
"bare view must ignore HighlightPaintChanged"
);
assert!(!st.needs_full_layout);
}
#[test]
fn bare_view_flow_snapshot_has_no_highlights() {
use std::sync::Arc;
use teksilo_text::text_document::{
FlowElementSnapshot, FlowSnapshot, FragmentContent, HighlightContext, HighlightFormat,
SyntaxHighlighter,
};
struct BoldHighlighter;
impl SyntaxHighlighter for BoldHighlighter {
fn highlight_block(&self, text: &str, ctx: &mut HighlightContext) {
let n = text.chars().count();
if n > 0 {
ctx.set_format(
0,
n,
HighlightFormat {
font_bold: Some(true),
..Default::default()
},
);
}
}
}
fn has_bold(flow: &FlowSnapshot) -> bool {
flow.elements.iter().any(|e| match e {
FlowElementSnapshot::Block(bs) => bs.fragments.iter().any(|f| {
matches!(f, FragmentContent::Text { format, .. } if format.font_bold == Some(true))
}),
_ => false,
})
}
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
doc.set_syntax_highlighter(Some(Arc::new(BoldHighlighter)));
let editor = RichTextEditor::editor(doc.clone());
assert!(
has_bold(&editor.state_handle().borrow().flow_snapshot()),
"editor view should keep the metric highlight"
);
let viewer = RichTextEditor::read_only(doc.clone());
assert!(
!has_bold(&viewer.state_handle().borrow().flow_snapshot()),
"bare viewer must drop the highlight even for a metric highlighter"
);
}
fn pump(tree: &mut WidgetTree, w: f32, h: f32) {
for _ in 0..4 {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(w, h));
}
}
fn nested_rich_text_fixture(
inner: crate::OverscrollBehavior,
) -> (
WidgetTree,
teksilo_core::signal::Signal<f32>,
teksilo_core::signal::Signal<f32>,
) {
use crate::ScrollArea;
use crate::primitives::{FixedSize, TextWidget, VStack};
let doc = TextDocument::new();
let text: String = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc).overscroll_behavior(inner);
let editor_y = editor.scroll_y();
{
let handle = editor.state_handle();
let mut s = handle.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 48.0;
s.engine.set_viewport(208.0, 48.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(48.0)
.child_id(editor_id),
);
let filler = tree.add(
FixedSize::new()
.width(220.0)
.height(300.0)
.child(TextWidget::new(lit!(""))),
);
let outer_content = tree.add(VStack::new().add_child(editor_box).add_child(filler));
let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(220.0, 150.0));
pump(&mut tree, 220.0, 150.0);
(tree, editor_y, outer_y)
}
#[test]
fn nested_rich_text_chains_to_outer_at_boundary() {
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, editor_y, outer_y) =
nested_rich_text_fixture(crate::OverscrollBehavior::Chain);
assert!(
editor_y.get().abs() < 0.01 && outer_y.get().abs() < 0.01,
"fixture starts unscrolled"
);
tree.pointer_move(Point::new(50.0, 12.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(220.0, 150.0));
let editor_bottom = editor_y.get();
assert!(
editor_bottom > 0.0,
"the editor should have scrolled its own content down; got {editor_bottom}"
);
assert!(
outer_y.get().abs() < 0.01,
"outer must not move while the editor still absorbs the scroll"
);
tree.pointer_move(Point::new(50.0, 12.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 120.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(220.0, 150.0));
assert!(
(editor_y.get() - editor_bottom).abs() < 0.01,
"editor stays clamped at its bottom"
);
assert!(
outer_y.get() > 0.01,
"outer must scroll because the editor chained the boundary wheel"
);
}
#[test]
fn nested_rich_text_contain_blocks_chaining() {
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, _editor_y, outer_y) =
nested_rich_text_fixture(crate::OverscrollBehavior::Contain);
for _ in 0..2 {
tree.pointer_move(Point::new(50.0, 12.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(220.0, 150.0));
}
assert!(
outer_y.get().abs() < 0.01,
"Contain must keep the wheel at the editor; outer stayed put"
);
}
#[test]
fn caret_move_chases_outer_scroll_area() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, TextWidget, VStack};
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let doc = TextDocument::new();
let text: String = (0..30)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
{
let handle = editor.state_handle();
let mut s = handle.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 150.0;
s.engine.set_viewport(208.0, 150.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(150.0)
.child_id(editor_id),
);
let filler = tree.add(
FixedSize::new()
.width(220.0)
.height(200.0)
.child(TextWidget::new(lit!(""))),
);
let outer_content = tree.add(VStack::new().add_child(editor_box).add_child(filler));
let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(220.0, 90.0));
pump(&mut tree, 220.0, 90.0);
tree.focus(editor_id);
tree.layout(SizeProposal::exact(220.0, 90.0));
outer_y.set(0.0);
tree.layout(SizeProposal::exact(220.0, 90.0));
assert!(outer_y.get().abs() < 0.01, "reset outer scroll to top");
for _ in 0..30 {
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::NONE,
text: None,
});
}
tree.layout(SizeProposal::exact(220.0, 90.0));
assert!(
outer_y.get() > 0.01,
"moving the caret below the outer fold must scroll the enclosing \
ScrollArea (got outer scroll {})",
outer_y.get()
);
}
#[test]
fn caret_follow_disabled_leaves_enclosing_page_alone() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let doc = TextDocument::new();
let text: String = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc).follow_caret_in_page(false);
{
let handle = editor.state_handle();
let mut s = handle.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 900.0;
s.engine.set_viewport(208.0, 900.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(900.0)
.child_id(editor_id),
);
let outer_content = tree.add(VStack::new().add_child(editor_box));
let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(220.0, 150.0));
tree.focus(editor_id);
pump(&mut tree, 220.0, 150.0);
let baseline = outer_y.get();
for _ in 0..30 {
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::NONE,
text: None,
});
}
tree.layout(SizeProposal::exact(220.0, 150.0));
pump(&mut tree, 220.0, 150.0);
assert!(
(outer_y.get() - baseline).abs() < 0.5,
"follow disabled: caret movement must not scroll the enclosing \
page; baseline={baseline} now={}",
outer_y.get()
);
}
#[test]
fn standalone_wheel_scroll_away_from_caret_stays() {
use crate::primitives::FixedSize;
use teksilo_core::event::{Key, Modifiers, ScrollDelta, WidgetEvent};
let doc = TextDocument::new();
let text: String = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
let editor_y = editor.scroll_y();
{
let handle = editor.state_handle();
let mut s = handle.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 100.0;
s.engine.set_viewport(208.0, 100.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let _box = tree.add(
FixedSize::new()
.width(220.0)
.height(100.0)
.child_id(editor_id),
);
tree.layout(SizeProposal::exact(220.0, 100.0));
pump(&mut tree, 220.0, 100.0);
tree.focus(editor_id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::Home,
modifiers: Modifiers::CTRL,
text: None,
});
pump(&mut tree, 220.0, 100.0);
assert!(editor_y.get().abs() < 0.5, "caret at top → scroll 0");
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 200.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(220.0, 100.0));
let after_scroll = editor_y.get();
assert!(
after_scroll > 0.5,
"wheel scrolled the editor down; got {after_scroll}"
);
pump(&mut tree, 220.0, 100.0);
assert!(
editor_y.get() > 0.5,
"view snapped back to caret after scrolling! scroll_y={} (was {after_scroll})",
editor_y.get()
);
}
#[test]
fn smooth_scroll_away_from_caret_stays() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let doc = TextDocument::new();
let text: String = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
{
let handle = editor.state_handle();
let mut s = handle.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 900.0; s.engine.set_viewport(208.0, 900.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(900.0)
.child_id(editor_id),
);
let outer_content = tree.add(VStack::new().add_child(editor_box));
let outer = ScrollArea::from_id(outer_content);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
let sz = SizeProposal::exact(220.0, 150.0);
tree.layout(sz);
tree.focus(editor_id);
pump(&mut tree, 220.0, 150.0);
outer_y.set(0.0);
tree.layout(sz);
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 300.0 },
modifiers: Modifiers::NONE,
});
for _ in 0..40 {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(sz);
}
assert!(
outer_y.get() > 0.5,
"page must stay scrolled away from the caret; ended at {}",
outer_y.get()
);
}
const IME_FIXTURE_SPACER_H: f32 = 300.0;
fn ime_flood_fixture() -> (
WidgetTree,
teksilo_core::widget_id::WidgetId,
super::super::state::SharedState,
teksilo_core::signal::Signal<f32>,
SizeProposal,
) {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
let doc = TextDocument::new();
let text: String = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
let st = editor.state_handle();
let editor_id = tree.add(editor);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(2000.0)
.child_id(editor_id),
);
let spacer = tree.add(
FixedSize::new()
.width(220.0)
.height(IME_FIXTURE_SPACER_H)
.child(crate::primitives::RectWidget::new()),
);
let outer_content = tree.add(VStack::new().add_child(spacer).add_child(editor_box));
let outer = ScrollArea::from_id(outer_content);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
let sz = SizeProposal::exact(220.0, 150.0);
tree.layout(sz);
pump(&mut tree, 220.0, 150.0);
tree.focus(editor_id);
pump(&mut tree, 220.0, 150.0);
outer_y.set(0.0);
tree.layout(sz);
let origin_y = st.borrow().viewport_origin.y;
assert!(
origin_y >= 150.0,
"fixture premise broken: with the page at rest the body must start \
below the 150dp page viewport, but viewport_origin.y = {origin_y}"
);
(tree, editor_id, st, outer_y, sz)
}
#[test]
fn empty_ime_composition_flood_does_not_follow_caret() {
use teksilo_core::event::WidgetEvent;
let (mut tree, _editor_id, _st, outer_y, sz) = ime_flood_fixture();
outer_y.set(0.0);
tree.layout(sz);
for _ in 0..20 {
tree.dispatch_event(WidgetEvent::ImeComposition {
text: String::new(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(sz);
}
assert!(
outer_y.get().abs() < 0.5,
"an empty IME composition flood must not scroll the page to the caret; \
page moved to {}",
outer_y.get()
);
}
#[test]
fn real_caret_move_still_follows_after_scroll_away() {
use teksilo_core::event::{Key, Modifiers};
let (mut tree, _editor_id, _st, outer_y, sz) = ime_flood_fixture();
outer_y.set(0.0);
tree.layout(sz);
press_key(&mut tree, Key::ArrowDown, Modifiers::NONE);
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(sz);
assert!(
outer_y.get() > 0.5,
"a real caret move must reveal the caret (page-follow intact); page at {}",
outer_y.get()
);
}
#[test]
fn repeated_same_preedit_does_not_re_follow_caret() {
use teksilo_core::event::WidgetEvent;
let (mut tree, _editor_id, _st, outer_y, sz) = ime_flood_fixture();
tree.layout(sz);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "x".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(sz);
assert!(
outer_y.get() > 0.5,
"first preedit should follow the caret into view; page at {}",
outer_y.get()
);
outer_y.set(0.0);
tree.layout(sz);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "x".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(sz);
assert!(
outer_y.get().abs() < 0.5,
"re-sending the same preedit must not snap the page back; page at {}",
outer_y.get()
);
}
#[test]
fn blur_clears_ime_area_cache() {
let doc_a = TextDocument::new();
doc_a.set_plain_text("a").unwrap();
let doc_b = TextDocument::new();
doc_b.set_plain_text("b").unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let ed_a = RichTextEditor::editor(doc_a);
let st_a = ed_a.state_handle();
let a_id = tree.add(ed_a);
let b_id = tree.add(RichTextEditor::editor(doc_b));
tree.layout(SizeProposal::exact(300.0, 120.0));
tree.focus(a_id);
st_a.borrow_mut().last_ime_area = Some(teksilo_canvas::Rect::new(1.0, 2.0, 2.0, 14.0));
tree.focus(b_id);
assert!(
st_a.borrow().last_ime_area.is_none(),
"blur must clear the IME-area dedup cache so a refocus re-seeds the OS"
);
}
#[test]
fn focus_reveals_caret_not_whole_editor_box() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, TextWidget, VStack};
let doc = TextDocument::new();
let text: String = (0..80)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
let st = editor.state_handle();
{
let mut s = st.borrow_mut();
s.viewport_width = 208.0;
s.viewport_height = 2000.0; s.engine.set_viewport(208.0, 2000.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let header = tree.add(
FixedSize::new()
.width(220.0)
.height(80.0)
.child(TextWidget::new(lit!(""))),
);
let editor_box = tree.add(
FixedSize::new()
.width(220.0)
.height(2000.0)
.child_id(editor_id),
);
let outer_content = tree.add(VStack::new().add_child(header).add_child(editor_box));
let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
let sz = SizeProposal::exact(220.0, 150.0);
tree.layout(sz);
pump(&mut tree, 220.0, 150.0);
tree.focus(editor_id);
tree.layout(sz);
assert!(
outer_y.get() < 60.0,
"focus must reveal the caret near the top, not scroll to the editor's bottom; page at {}",
outer_y.get()
);
}
#[test]
fn select_range_selects_without_collapsing() {
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let editor = RichTextEditor::read_only(doc);
let anchor = editor.cursor_anchor_signal();
let caret = editor.cursor_position_signal();
let has_sel = editor.has_selection();
editor.select_range(6, 11);
assert!(has_sel.get(), "select_range must produce a real selection");
assert_eq!(anchor.get(), 6, "anchor at the range start");
assert_eq!(caret.get(), 11, "caret at the range end");
assert_eq!(editor.selected_text(), "world");
editor.set_caret_position(3);
assert!(!has_sel.get());
}
#[test]
fn focused_signal_reads_focus_state() {
let doc = TextDocument::new();
doc.set_plain_text("text").unwrap();
let editor = RichTextEditor::read_only(doc);
assert!(!editor.focused_signal().get());
}
}
#[test]
fn editor_handle_reads_its_content_back_as_djot() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.insert_djot("# Title\n\nSome prose.\n");
let out = handle.to_djot();
assert!(out.contains("Title"), "heading survives: {out}");
assert!(out.contains("Some prose."), "body survives: {out}");
assert_eq!(
out,
doc.to_djot().unwrap_or_default(),
"same as the document"
);
}
#[test]
fn editor_handle_reports_emptiness() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
assert!(handle.is_empty(), "a fresh editor holds nothing");
handle.insert_text("x");
assert!(!handle.is_empty());
}
#[test]
fn editor_handle_counts_whitespace_as_content() {
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.insert_text(" ");
assert!(!handle.is_empty(), "spaces are characters");
assert!(
handle.to_djot().trim().is_empty(),
"but nothing worth keeping"
);
}
#[test]
fn editor_handle_insert_text_keeps_a_newline_inside_one_block() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
handle.insert_text("\nsecond");
let djot = doc.to_djot().unwrap_or_default();
assert!(
!djot.contains("\n\n"),
"insert_text must not create a second block, got:\n{djot}"
);
}
#[test]
fn editor_handle_insert_block_splits_the_current_block() {
let doc = TextDocument::new();
doc.set_plain_text("firstsecond").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
handle.insert_block();
let djot = doc.to_djot().unwrap_or_default();
assert!(
djot.contains("first\n\nsecond"),
"insert_block must split into two paragraphs, got:\n{djot}"
);
}
#[test]
fn editor_handle_insert_djot_merges_a_single_paragraph_at_the_caret() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
handle.insert_djot("\\* \\* \\*");
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
plain.contains("* * *"),
"the escaped marker's literal text must survive, got:\n{plain}"
);
}
#[test]
fn editor_handle_insert_djot_carries_a_multi_block_fragment() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
handle.insert_djot("one\n\ntwo");
let djot = doc.to_djot().unwrap_or_default();
assert!(
djot.contains("\n\n"),
"a two-block fragment must produce two blocks, got:\n{djot}"
);
}
#[test]
fn insert_paragraph_lands_a_marker_on_its_own_line() {
let doc = TextDocument::new();
doc.set_plain_text("beforeafter").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(6, 6);
handle.insert_paragraph("* * *");
let djot = doc.to_djot().unwrap_or_default();
assert!(
djot.contains("before\n\n\\* \\* \\*\n\nafter"),
"marker must be its own paragraph between the halves, got:\n{djot}"
);
}
#[test]
fn an_unescaped_thematic_break_is_dropped_by_the_parser() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
handle.insert_djot("\n\n* * *\n");
let plain = doc.to_plain_text().unwrap_or_default();
assert!(
!plain.contains("* * *"),
"an unescaped thematic break is expected to be dropped, got:\n{plain}"
);
}
#[test]
fn superscript_round_trips_through_the_handle() {
let doc = TextDocument::new();
doc.set_plain_text("E=mc2").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
handle.select_range(4, 5);
assert!(!handle.is_superscript());
handle.toggle_superscript();
assert!(handle.is_superscript(), "superscript must actually apply");
handle.toggle_superscript();
assert!(!handle.is_superscript(), "and must come back off");
}
#[test]
fn superscript_and_subscript_are_mutually_exclusive() {
let doc = TextDocument::new();
doc.set_plain_text("H2O").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
handle.select_range(1, 2);
handle.toggle_subscript();
assert!(handle.is_subscript() && !handle.is_superscript());
handle.toggle_superscript();
assert!(
handle.is_superscript() && !handle.is_subscript(),
"raising a subscript run must clear the subscript"
);
}
const SHALOM: &str = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}";
fn rtl_editor(text: &str, at: usize) -> (WidgetTree, teksilo_core::signal::Signal<usize>) {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc);
let caret = editor.cursor_position_signal();
let handle = editor.handle();
let mut tree = WidgetTree::new();
tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
position: teksilo_canvas::Point::new(1.0, 8.0),
button: teksilo_core::event::PointerButton::Primary,
modifiers: teksilo_core::event::Modifiers::NONE,
});
handle.select_range(at, at);
assert_eq!(caret.get(), at, "caret should start at {at}");
(tree, caret)
}
fn press(tree: &mut WidgetTree, key: teksilo_core::event::Key) {
tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key,
modifiers: teksilo_core::event::Modifiers::NONE,
text: None,
});
}
#[test]
fn arrow_keys_follow_the_screen_in_rtl_text() {
use teksilo_core::event::Key;
let (mut tree, caret) = rtl_editor(SHALOM, 2);
press(&mut tree, Key::ArrowRight);
assert_eq!(
caret.get(),
1,
"ArrowRight in RTL text must step logically backwards so the \
caret moves right on screen"
);
press(&mut tree, Key::ArrowLeft);
assert_eq!(caret.get(), 2, "ArrowLeft must undo it");
press(&mut tree, Key::ArrowLeft);
assert_eq!(caret.get(), 3, "and keep travelling logically forwards");
}
#[test]
fn arrow_keys_are_unchanged_in_ltr_text() {
use teksilo_core::event::Key;
let (mut tree, caret) = rtl_editor("Hello world", 2);
press(&mut tree, Key::ArrowRight);
assert_eq!(caret.get(), 3, "ArrowRight still advances in LTR text");
press(&mut tree, Key::ArrowLeft);
assert_eq!(caret.get(), 2, "ArrowLeft still retreats in LTR text");
}
#[test]
fn home_and_end_reach_the_logical_ends_of_an_rtl_line() {
use teksilo_core::event::Key;
let len = SHALOM.chars().count();
let (mut tree, caret) = rtl_editor(SHALOM, 2);
press(&mut tree, Key::Home);
assert_eq!(caret.get(), 0, "Home must reach the logical start");
press(&mut tree, Key::End);
assert_eq!(caret.get(), len, "End must reach the logical end");
}
#[test]
fn home_and_end_still_work_in_ltr_text() {
use teksilo_core::event::Key;
let text = "Hello world";
let (mut tree, caret) = rtl_editor(text, 4);
press(&mut tree, Key::Home);
assert_eq!(caret.get(), 0);
press(&mut tree, Key::End);
assert_eq!(caret.get(), text.chars().count());
}
type PinRecord = Rc<Cell<Option<teksilo_core::event::ScrollAlign>>>;
fn typewriter_fixture(
anchor: Option<f32>,
) -> (
WidgetTree,
super::EditorHandle,
PinRecord,
teksilo_core::widget_id::WidgetId,
) {
use super::ScrollPolicy;
use crate::primitives::VStack;
use teksilo_core::event::{EventResponse, WidgetEvent};
use teksilo_core::widget_builder::WidgetBuilder;
let doc = TextDocument::new();
doc.set_plain_text(
&(1..=40)
.map(|i| format!("Line {i}"))
.collect::<Vec<_>>()
.join("\n"),
)
.unwrap();
let editor = RichTextEditor::editor(doc)
.min_lines(1)
.v_scroll_policy(ScrollPolicy::AlwaysOff)
.typewriter(anchor);
let handle = editor.handle();
let recorded: PinRecord = Rc::new(Cell::new(None));
let mut tree = WidgetTree::new();
let id = tree.add(editor);
let rec = recorded.clone();
let _container = tree.add(
VStack::new()
.add_child(id)
.on_scroll(move |ev, _ctx| match ev {
WidgetEvent::ScrollIntoView { align, .. } => {
rec.set(Some(*align));
EventResponse::Handled
}
_ => EventResponse::Ignored,
})
.clips_children(true),
);
tree.layout(SizeProposal::exact(400.0, 120.0));
let _ = tree.render();
(tree, handle, recorded, id)
}
fn click_at(tree: &mut WidgetTree, y: f32) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(20.0, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(20.0, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn key(tree: &mut WidgetTree, key: teksilo_core::event::Key) {
use teksilo_core::event::{Modifiers, WidgetEvent};
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::NONE,
text: None,
});
}
#[test]
fn a_keyboard_caret_move_pins_when_typewriter_is_on() {
use teksilo_core::event::{Key, ScrollAlign};
let (mut tree, _handle, recorded, _id) = typewriter_fixture(Some(0.5));
click_at(&mut tree, 8.0);
recorded.set(None);
key(&mut tree, Key::ArrowDown);
assert_eq!(
recorded.get(),
Some(ScrollAlign::Fraction(0.5)),
"a keyboard caret move must ask the enclosing page for a pin"
);
}
#[test]
fn without_typewriter_a_caret_move_only_reveals_minimally() {
use teksilo_core::event::{Key, ScrollAlign};
let (mut tree, _handle, recorded, _id) = typewriter_fixture(None);
click_at(&mut tree, 8.0);
recorded.set(None);
for _ in 0..30 {
key(&mut tree, Key::ArrowDown);
}
assert_eq!(
recorded.get(),
Some(ScrollAlign::Minimal),
"with typewriter off the page must keep its plain reveal behaviour"
);
}
#[test]
fn a_mouse_click_never_pins() {
let (mut tree, _handle, recorded, _id) = typewriter_fixture(Some(0.5));
click_at(&mut tree, 8.0);
recorded.set(None);
click_at(&mut tree, 60.0);
assert!(
!matches!(
recorded.get(),
Some(teksilo_core::event::ScrollAlign::Fraction(_))
),
"clicking must not pin — it is the gesture that stands the pin down"
);
}
#[test]
fn the_next_keystroke_resumes_pinning_after_a_click() {
use teksilo_core::event::{Key, ScrollAlign};
let (mut tree, _handle, recorded, _id) = typewriter_fixture(Some(0.5));
click_at(&mut tree, 8.0);
click_at(&mut tree, 60.0);
recorded.set(None);
key(&mut tree, Key::ArrowDown);
assert_eq!(
recorded.get(),
Some(ScrollAlign::Fraction(0.5)),
"the pin must take over again as soon as the keyboard is used"
);
}
#[test]
fn the_anchor_is_clamped_into_the_viewport() {
let (_tree, handle, _recorded, _id) = typewriter_fixture(Some(4.2));
assert_eq!(
handle.get_typewriter(),
Some(1.0),
"an out-of-range anchor must clamp rather than aim the pin off-screen"
);
handle.set_typewriter(Some(-3.0));
assert_eq!(handle.get_typewriter(), Some(0.0));
}
#[test]
fn set_typewriter_toggles_pinning_live() {
use teksilo_core::event::{Key, ScrollAlign};
let (mut tree, handle, recorded, _id) = typewriter_fixture(None);
click_at(&mut tree, 8.0);
recorded.set(None);
key(&mut tree, Key::ArrowDown);
assert!(!matches!(recorded.get(), Some(ScrollAlign::Fraction(_))));
handle.set_typewriter(Some(0.25));
recorded.set(None);
key(&mut tree, Key::ArrowDown);
assert_eq!(recorded.get(), Some(ScrollAlign::Fraction(0.25)));
handle.set_typewriter(None);
recorded.set(None);
key(&mut tree, Key::ArrowDown);
assert!(!matches!(recorded.get(), Some(ScrollAlign::Fraction(_))));
}
#[test]
fn a_pin_re_asserts_after_a_reflow_at_the_same_caret_offset() {
use teksilo_core::event::{Key, ScrollAlign};
let (mut tree, handle, recorded, _id) = typewriter_fixture(Some(0.5));
click_at(&mut tree, 8.0);
key(&mut tree, Key::ArrowDown);
handle.set_font_size_scale(2.0);
for _ in 0..3 {
tick_once(&mut tree);
}
recorded.set(None);
key(&mut tree, Key::ArrowLeft);
key(&mut tree, Key::ArrowRight);
assert_eq!(
recorded.get(),
Some(ScrollAlign::Fraction(0.5)),
"a pin must survive a reflow that leaves the caret offset untouched"
);
}
#[test]
fn a_drag_selection_is_never_interrupted_by_the_pin() {
use teksilo_core::event::{Modifiers, PointerButton, ScrollAlign, WidgetEvent};
let (mut tree, _handle, recorded, _id) = typewriter_fixture(Some(0.5));
click_at(&mut tree, 8.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(20.0, 8.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
recorded.set(None);
for y in [20.0, 40.0, 60.0, 90.0] {
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(20.0, y),
});
}
assert!(
!matches!(recorded.get(), Some(ScrollAlign::Fraction(_))),
"the view must hold still while a selection is being dragged — \
re-centring mid-drag is what makes precise selection impossible"
);
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(20.0, 90.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
recorded.set(None);
key(&mut tree, teksilo_core::event::Key::ArrowDown);
assert_eq!(recorded.get(), Some(ScrollAlign::Fraction(0.5)));
}
mod caret_band {
use super::*;
use crate::rich_text::caret_highlight::{CaretHighlight, CaretHighlightScope};
use teksilo_text::text_document::{Color, FlowElementSnapshot, HighlightFormat, HighlightMask};
const BAND: Color = Color {
red: 255,
green: 254,
blue: 235,
alpha: 255,
};
pub(super) fn band(scope: CaretHighlightScope) -> CaretHighlight {
CaretHighlight {
scope,
format: HighlightFormat {
background_color: Some(BAND),
..Default::default()
},
content_locale: Some("en".into()),
}
}
pub(super) fn banded(doc: &TextDocument) -> Vec<(usize, usize)> {
match &doc.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
FlowElementSnapshot::Block(b) => b
.paint_highlights
.iter()
.filter(|s| s.background_color == Some(BAND))
.map(|s| (s.start, s.length))
.collect(),
_ => panic!("expected a block"),
}
}
pub(super) struct Mounted {
pub tree: WidgetTree,
pub doc: TextDocument,
pub handle: crate::rich_text::EditorHandle,
pub state: crate::rich_text::state::SharedState,
}
pub(super) fn mounted(text: &str) -> Mounted {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tick_once(&mut tree);
Mounted {
tree,
doc,
handle,
state,
}
}
#[test]
fn setting_a_band_paints_the_caret_sentence() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first. Two is second.");
assert!(banded(&doc).is_empty(), "no band before it is asked for");
handle.select_range(16, 16);
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
tick_once(&mut tree);
assert_eq!(banded(&doc), [(14, 14)], "\"Two is second.\" is banded");
}
#[test]
fn the_band_follows_the_caret_and_the_scope() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first. Two is second.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
handle.select_range(2, 2);
tick_once(&mut tree);
assert_eq!(banded(&doc), [(0, 13)]);
handle.select_range(16, 16);
tick_once(&mut tree);
assert_eq!(banded(&doc), [(14, 14)], "it followed the caret");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Paragraph)));
tick_once(&mut tree);
assert_eq!(banded(&doc), [(0, 28)], "and widened with the scope");
}
#[test]
fn clearing_the_band_removes_it_from_the_document() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
tick_once(&mut tree);
assert!(!banded(&doc).is_empty());
handle.set_caret_highlight(None);
tick_once(&mut tree);
assert!(banded(&doc).is_empty(), "and its session is gone");
assert_eq!(handle.get_caret_highlight(), None);
}
#[test]
fn only_the_focused_pane_of_a_split_bands() {
let doc = TextDocument::new();
doc.set_plain_text("One is first. Two is second.").unwrap();
let left = RichTextEditor::editor(doc.clone());
let left_handle = left.handle();
let right = RichTextEditor::editor(doc.clone());
let right_handle = right.handle();
let mut tree = WidgetTree::new();
let left_id = tree.add(left);
let right_id = tree.add(right);
tree.layout(SizeProposal::exact(400.0, 300.0));
left_handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
right_handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
left_handle.select_range(2, 2);
right_handle.select_range(16, 16);
tree.focus(left_id);
tick_once(&mut tree);
assert_eq!(
banded(&doc),
[(0, 13)],
"exactly one band, at the focused pane's caret"
);
tree.focus(right_id);
tick_once(&mut tree);
tick_once(&mut tree);
assert_eq!(
banded(&doc),
[(14, 14)],
"still exactly one band, now at the other pane's caret"
);
}
#[test]
fn moving_the_band_recolors_one_block_and_never_relayouts() {
let Mounted {
mut tree,
handle,
state,
..
} = mounted("One is first. Two is second.\nA second paragraph.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
tick_once(&mut tree);
{
let mut st = state.borrow_mut();
st.needs_full_layout = false;
st.pending_recolor = false;
st.pending_recolor_range = None;
}
handle.select_range(16, 16);
{
let mut st = state.borrow_mut();
let caret = st.cursor.position();
let changed = st
.caret_highlight
.as_ref()
.expect("a band was set")
.refresh(caret);
assert!(changed, "the caret moved to another sentence");
st.drain_events();
}
let st = state.borrow();
assert!(st.pending_recolor, "a band move asks for a recolor");
assert!(
!st.needs_full_layout,
"a band move must never force a reshape"
);
let (start, length) = st
.pending_recolor_range
.expect("the extent must be known, or the fast path cannot fire");
assert!(
length > 0 && start + length <= 28,
"the extent must stay inside the first block; got {start}..{}",
start + length
);
}
#[test]
fn a_read_only_view_paints_no_band() {
let doc = TextDocument::new();
doc.set_plain_text("One is first.").unwrap();
let editor = RichTextEditor::read_only(doc.clone());
let handle = editor.handle();
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tick_once(&mut tree);
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
tick_once(&mut tree);
assert!(
!state.borrow().show_highlights,
"read_only defaults highlights off"
);
assert!(
!state.borrow().pending_recolor,
"so a band change is a pure no-op for this view"
);
}
}
#[cfg(test)]
mod caret_band_selection {
use super::caret_band::*;
use super::*;
use crate::rich_text::caret_highlight::CaretHighlightScope;
#[test]
fn a_selection_hides_the_band() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first. Two is second. Three is third.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
handle.select_range(2, 2);
tick_once(&mut tree);
assert_eq!(banded(&doc), [(0, 13)], "a collapsed caret bands");
handle.select_range(2, 20);
tick_once(&mut tree);
assert!(
banded(&doc).is_empty(),
"no band while a selection is up; got {:?}",
banded(&doc)
);
handle.select_range(20, 20);
tick_once(&mut tree);
assert_eq!(banded(&doc), [(14, 14)], "and it returns on collapse");
}
#[test]
fn growing_a_selection_never_flickers_the_band() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first. Two is second. Three is third.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
handle.select_range(2, 2);
tick_once(&mut tree);
for head in 3..=42 {
handle.select_range(2, head);
tick_once(&mut tree);
assert!(
banded(&doc).is_empty(),
"band reappeared mid-drag at head {head}: {:?}",
banded(&doc)
);
}
}
#[test]
fn a_backwards_selection_hides_the_band_too() {
let Mounted {
mut tree,
doc,
handle,
..
} = mounted("One is first. Two is second.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
handle.select_range(25, 5);
tick_once(&mut tree);
assert!(banded(&doc).is_empty(), "got {:?}", banded(&doc));
}
}
#[cfg(test)]
mod caret_band_regressions {
use super::caret_band::*;
use super::*;
use crate::rich_text::caret_highlight::CaretHighlightScope;
#[test]
fn parking_an_editor_retires_its_band() {
let doc = TextDocument::new();
doc.set_plain_text("One is first. Two is second.").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tick_once(&mut tree);
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
tick_once(&mut tree);
assert!(!banded(&doc).is_empty(), "banded while live and focused");
tree.set_dormant(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
banded(&doc).is_empty(),
"a dormant editor must leave no band on a document its siblings still show"
);
}
#[test]
fn a_band_move_survives_a_full_layout_in_the_same_tick() {
let Mounted {
mut tree,
doc,
handle,
state,
} = mounted("One is first. Two is second.");
handle.set_caret_highlight(Some(band(CaretHighlightScope::Sentence)));
handle.select_range(2, 2);
tick_once(&mut tree);
let _ = tree.render();
tick_once(&mut tree);
let _ = tree.render();
assert_eq!(banded(&doc), [(0, 13)], "banding the first sentence");
let painted = |state: &crate::rich_text::state::SharedState| -> Vec<(i64, i64)> {
state.borrow_mut().engine.with_render_frame(|frame| {
let mut v: Vec<(i64, i64)> = frame
.decorations
.iter()
.filter(|d| d.kind == teksilo_text::TypesetterDecorationKind::TextBackground)
.map(|d| {
(
d.rect[0].round() as i64,
(d.rect[0] + d.rect[2]).round() as i64,
)
})
.collect();
v.sort();
v
})
};
let first = painted(&state);
assert!(!first.is_empty(), "the first sentence is painted");
state.borrow_mut().needs_full_layout = true;
handle.select_range(16, 16);
tick_once(&mut tree);
let _ = tree.render();
assert_eq!(
banded(&doc),
[(14, 14)],
"the document carries the new extent (this part was never broken)"
);
let second = painted(&state);
assert!(!second.is_empty(), "the second sentence is painted");
assert_ne!(
first, second,
"the PAINTED band must move too — it used to keep the pre-edit extent, because \
layout_full baked the old band and the recolor step then dropped the correction"
);
}
}
#[cfg(test)]
mod vertical_movement {
use super::*;
use teksilo_core::{Key, Modifiers, WidgetEvent};
use teksilo_text::EditorTypographyDefaults;
fn editor_with_line_height(line_height: f32) -> (crate::rich_text::EditorHandle, WidgetTree) {
let doc = TextDocument::new();
doc.set_plain_text("Line one here.\nLine two here.\nLine three here.\nLine four here.")
.unwrap();
let editor = RichTextEditor::editor(doc).typography_defaults(EditorTypographyDefaults {
line_height,
..Default::default()
});
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tick_once(&mut tree);
let _ = tree.render();
(handle, tree)
}
fn press(tree: &mut WidgetTree, key: Key) {
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::NONE,
text: None,
});
tick_once(tree);
}
#[test]
fn arrow_down_crosses_a_line_at_every_line_height() {
for line_height in [1.0f32, 1.2, 1.5, 1.6, 2.0] {
let (handle, mut tree) = editor_with_line_height(line_height);
handle.select_range(2, 2);
tick_once(&mut tree);
let _ = tree.render();
press(&mut tree, Key::ArrowDown);
let after = handle.cursor_position();
assert!(
after >= 15,
"line_height {line_height}: ArrowDown must reach the second line, got {after}"
);
}
}
#[test]
fn arrow_up_crosses_a_line_at_every_line_height() {
for line_height in [1.0f32, 1.6, 2.0] {
let (handle, mut tree) = editor_with_line_height(line_height);
handle.select_range(20, 20); tick_once(&mut tree);
let _ = tree.render();
press(&mut tree, Key::ArrowUp);
let after = handle.cursor_position();
assert!(
after < 15,
"line_height {line_height}: ArrowUp must reach the first line, got {after}"
);
}
}
#[test]
fn down_then_up_returns_to_the_starting_line() {
for line_height in [1.0f32, 1.6] {
let (handle, mut tree) = editor_with_line_height(line_height);
handle.select_range(2, 2);
tick_once(&mut tree);
let _ = tree.render();
press(&mut tree, Key::ArrowDown);
let middle = handle.cursor_position();
press(&mut tree, Key::ArrowUp);
let back = handle.cursor_position();
assert!(middle > 2, "line_height {line_height}: down moved");
assert_eq!(back, 2, "line_height {line_height}: and up came back");
}
}
#[test]
fn vertical_movement_stops_at_the_document_edges() {
let (handle, mut tree) = editor_with_line_height(1.6);
handle.select_range(0, 0);
tick_once(&mut tree);
let _ = tree.render();
press(&mut tree, Key::ArrowUp);
assert_eq!(handle.cursor_position(), 0, "no running off the top");
let end = 60;
handle.select_range(end, end);
tick_once(&mut tree);
let _ = tree.render();
for _ in 0..5 {
press(&mut tree, Key::ArrowDown);
}
assert!(handle.cursor_position() <= 61, "no running off the bottom");
}
}
#[allow(unused_imports)]
use crate::rich_text::TextAnnotationSpan;
#[test]
fn an_annotated_range_emits_a_comment_node_linked_by_details() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("The lamp guttered").unwrap();
let editor = RichTextEditor::editor(doc).annotation_spans(vec![TextAnnotationSpan {
start: 4,
end: 8,
group_id: 77,
summary: "Jane: is this too on-the-nose?".into(),
}]);
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let comment = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Comment)
.expect("an annotated range must emit a Role::Comment body node");
assert_eq!(
comment.1.value(),
Some("Jane: is this too on-the-nose?"),
"the body carries the text to announce — the marked span must not, since \
role=mark forbids an accessible name"
);
let linked = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::TextRun)
.any(|(_, n)| n.details().contains(&comment.0));
assert!(
linked,
"the TextRun covering the annotation must point at the comment via details"
);
}
#[test]
fn an_unannotated_document_emits_no_comment_nodes() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("The lamp guttered").unwrap();
let mut tree = WidgetTree::new();
let _id = tree.add(RichTextEditor::editor(doc));
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
assert!(
!update.nodes.iter().any(|(_, n)| n.role() == Role::Comment),
"an unannotated document must emit no annotation bodies"
);
}
#[test]
fn overlapping_annotations_each_get_their_own_body_node() {
use teksilo_core::accesskit::Role;
let doc = TextDocument::new();
doc.set_plain_text("The lamp guttered").unwrap();
let editor = RichTextEditor::editor(doc).annotation_spans(vec![
TextAnnotationSpan {
start: 0,
end: 8,
group_id: 1,
summary: "first".into(),
},
TextAnnotationSpan {
start: 4,
end: 17,
group_id: 2,
summary: "second".into(),
},
]);
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let update = tree.sync_accessibility();
let bodies: Vec<&str> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Comment)
.filter_map(|(_, n)| n.value())
.collect();
assert!(bodies.contains(&"first"), "got {bodies:?}");
assert!(bodies.contains(&"second"), "got {bodies:?}");
}
#[test]
fn range_rect_reports_a_plausible_box_for_a_laid_out_span() {
let doc = TextDocument::new();
doc.set_plain_text("The lamp guttered, and then it did not.")
.unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
let r = handle
.range_rect(4, 8)
.expect("a laid-out editor must report a rect for a real range");
assert!(r.width > 0.0, "a four-character span has width: {r:?}");
assert!(r.height > 0.0, "and a line's height: {r:?}");
}
#[test]
fn range_rect_advances_along_the_line() {
let doc = TextDocument::new();
doc.set_plain_text("aaaa bbbb cccc").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(600.0, 300.0));
let _ = tree.render();
let first = handle.range_rect(0, 4).expect("first word");
let last = handle.range_rect(10, 14).expect("last word");
assert!(
last.x > first.x,
"a later span must start further right: {first:?} then {last:?}"
);
}
#[test]
fn range_rect_is_none_before_layout() {
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
assert!(handle.range_rect(0, 5).is_none());
}
fn body_height(tree: &WidgetTree, id: teksilo_core::widget_id::WidgetId) -> f32 {
fn walk(
tree: &WidgetTree,
id: teksilo_core::widget_id::WidgetId,
) -> Option<teksilo_core::widget_id::WidgetId> {
if tree
.widget_type_name(id)
.is_some_and(|n| n.ends_with("RichTextEditorBody"))
{
return Some(id);
}
tree.children(id).into_iter().find_map(|c| walk(tree, c))
}
walk(tree, id).map(|b| tree.bounds(b).height).unwrap_or(0.0)
}
#[test]
fn an_unlaid_out_editor_estimates_its_height_from_its_text() {
fn measured(text: &str) -> f32 {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc)
.min_lines(10)
.estimate_height_before_layout(true);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 3000.0));
tree.layout(SizeProposal::exact(400.0, 3001.0));
body_height(&tree, id)
}
let empty = measured("");
let short = measured("One line.");
let long = measured(&"The ferry left before the light did. ".repeat(200));
assert!(
(empty - short).abs() < 1.0,
"nothing and almost nothing both sit on the floor: {empty} against {short}"
);
assert!(
long > short * 4.0,
"a document forty times longer must not measure the same: {long} against {short}"
);
}
#[test]
fn a_narrower_measure_estimates_a_taller_block_of_text() {
fn at(width: f32) -> f32 {
let doc = TextDocument::new();
doc.set_plain_text(&"The ferry left before the light did. ".repeat(200))
.unwrap();
let editor = RichTextEditor::editor(doc)
.min_lines(1)
.estimate_height_before_layout(true);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(width, 3000.0));
tree.layout(SizeProposal::exact(width, 3001.0));
body_height(&tree, id)
}
let wide = at(800.0);
let narrow = at(200.0);
assert!(
narrow > wide * 2.0,
"a quarter of the measure is roughly four times the lines: {narrow} at 200 px \
against {wide} at 800 px"
);
}
#[test]
fn the_estimate_counts_the_line_height_and_the_paragraph_spacing() {
fn at(defaults: teksilo_text::EditorTypographyDefaults) -> f32 {
let doc = TextDocument::new();
doc.set_plain_text(&"The ferry left before the light did.\n\n".repeat(30))
.unwrap();
let editor = RichTextEditor::editor(doc)
.min_lines(1)
.typography_defaults(defaults)
.estimate_height_before_layout(true);
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 6000.0));
tree.layout(SizeProposal::exact(400.0, 6001.0));
body_height(&tree, id)
}
let plain = at(teksilo_text::EditorTypographyDefaults::default());
let spaced = at(teksilo_text::EditorTypographyDefaults {
line_height: 1.6,
..Default::default()
});
let gapped = at(teksilo_text::EditorTypographyDefaults {
paragraph_spacing_before: 6.0,
paragraph_spacing_after: 6.0,
..Default::default()
});
assert!(
spaced > plain * 1.4,
"a 1.6 line-height multiplier must show: {spaced} against {plain}"
);
assert!(
gapped > plain,
"twelve pixels between each of thirty paragraphs is real page: {gapped} against {plain}"
);
}
#[test]
fn the_estimate_is_close_to_the_truth_and_is_not_a_floor() {
fn ratio(text: &str, typography: teksilo_text::EditorTypographyDefaults) -> f32 {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc)
.min_lines(10)
.typography_defaults(typography)
.estimate_height_before_layout(true);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 6000.0));
tree.layout(SizeProposal::exact(400.0, 6001.0));
let guessed = body_height(&tree, id);
assert!(handle.content_height().is_none(), "nothing laid out yet");
let _ = tree.render();
guessed / handle.content_height().expect("laid out now")
}
let manuscript = teksilo_text::EditorTypographyDefaults {
line_height: 1.6,
..Default::default()
};
for (name, text, typo) in [
(
"one long paragraph",
"The ferry left before the light did. ".repeat(200),
Default::default(),
),
(
"many short paragraphs",
"The ferry left.\n\n".repeat(120),
Default::default(),
),
(
"manuscript line height",
"The ferry left before the light did. ".repeat(200),
manuscript,
),
] {
let r = ratio(&text, typo);
assert!(
(0.75..=1.35).contains(&r),
"{name}: the guess was a factor of {r} off, which a reader would see correct itself"
);
}
let doc = TextDocument::new();
doc.set_plain_text(&"The ferry left before the light did. ".repeat(200))
.unwrap();
let capped = RichTextEditor::editor(doc)
.min_lines(2)
.max_lines(4)
.estimate_height_before_layout(true);
let mut tree = WidgetTree::new();
let id = tree.add(capped);
tree.layout(SizeProposal::exact(400.0, 6000.0));
tree.layout(SizeProposal::exact(400.0, 6001.0));
let h = body_height(&tree, id);
assert!(
h < 200.0,
"`max_lines` must cap the estimate as it caps a real height: got {h}"
);
}
#[cfg(test)]
fn tall_editor() -> (
RichTextEditor,
crate::rich_text::EditorHandle,
teksilo_core::signal::Signal<f32>,
) {
let doc = TextDocument::new();
let mut text = String::new();
for i in 0..60 {
text.push_str(&format!(
"Paragraph {i}: the ferry left before the light did.\n"
));
}
doc.set_plain_text(&text).unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let scroll = editor.scroll_y();
(editor, handle, scroll)
}
#[test]
fn content_rect_ignores_scrolling_where_the_window_rect_follows_it() {
let (editor, handle, scroll) = tall_editor();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 200.0));
let _ = tree.render();
let window_before = handle.range_rect(100, 110).expect("laid out");
let content_before = handle.range_content_rect(100, 110).expect("laid out");
scroll.set(300.0);
let window_after = handle.range_rect(100, 110).expect("still laid out");
let content_after = handle.range_content_rect(100, 110).expect("still laid out");
assert!(
(window_before.y - window_after.y).abs() > 100.0,
"the window rect must follow the scroll: {window_before:?} then {window_after:?}"
);
assert!(
(content_before.y - content_after.y).abs() < 0.01,
"the content rect must not: {content_before:?} then {content_after:?}"
);
}
#[test]
fn content_rect_over_content_height_is_a_monotone_document_fraction() {
let (editor, handle, _scroll) = tall_editor();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 200.0));
let _ = tree.render();
let height = handle.content_height().expect("laid out");
assert!(
height > 200.0,
"the document is taller than its viewport: {height}"
);
let fraction = |offset: usize| handle.offset_content_rect(offset).unwrap().y / height;
let first = fraction(0);
let middle = fraction(1500);
let last = fraction(3000);
assert!(
(0.0..=1.0).contains(&first) && (0.0..=1.0).contains(&last),
"fractions stay inside the document: {first} .. {last}"
);
assert!(
first < middle && middle < last,
"and rise with the offset: {first}, {middle}, {last}"
);
}
#[test]
fn content_geometry_is_absent_together_before_layout() {
let doc = TextDocument::new();
doc.set_plain_text("hello").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
assert!(handle.range_content_rect(0, 5).is_none());
assert!(handle.offset_content_rect(0).is_none());
assert!(handle.content_height().is_none());
}
#[test]
fn external_drag_hover_moves_caret_from_formats_only_payload() {
use teksilo_core::ExternalDropData;
use teksilo_core::window::NoopWindowOps;
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
handle.select_range(0, 0);
let before = handle.cursor_position();
let mut noop = NoopWindowOps;
tree.begin_external_drag(
Point::new(300.0, 8.0),
ExternalDropData {
formats: vec!["text/uri-list".to_string()],
..Default::default()
},
&mut noop,
);
assert_ne!(
before,
handle.cursor_position(),
"the caret must follow an external drag whose payload carries only MIME \
formats (the Wayland hover shape)"
);
}
fn drag_fixture(text: &str) -> (super::state::SharedState, super::EditorHandle, WidgetTree) {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let state = editor.state.clone();
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
(state, handle, tree)
}
fn drag_of(
state: &super::state::SharedState,
handle: &super::EditorHandle,
range: (usize, usize),
drop_at: usize,
) -> super::EditorTextDrag {
handle.select_range(range.0, range.1);
let fragment = state.borrow().cursor.selection();
let text = fragment.to_plain_text().to_string();
handle.select_range(drop_at, drop_at);
super::EditorTextDrag {
source: teksilo_core::WidgetId::default(),
range,
fragment,
text,
}
}
#[test]
fn text_dropped_into_its_own_editor_moves() {
let (state, handle, _tree) = drag_fixture("Hello world");
let drag = drag_of(&state, &handle, (0, 5), 11);
assert!(super::mouse::apply_text_drop(&state, &drag, true));
assert_eq!(
handle.to_plain_text(),
" worldHello",
"the original must be removed and the drop re-based past it"
);
}
#[test]
fn text_dropped_into_another_editor_copies() {
let (state, handle, _tree) = drag_fixture("Hello world");
let drag = drag_of(&state, &handle, (0, 5), 11);
assert!(super::mouse::apply_text_drop(&state, &drag, false));
assert_eq!(
handle.to_plain_text(),
"Hello worldHello",
"a cross-editor drop copies, leaving the source text standing"
);
}
#[test]
fn text_dropped_inside_itself_changes_nothing() {
let (state, handle, _tree) = drag_fixture("Hello world");
let drag = drag_of(&state, &handle, (0, 5), 3);
assert!(super::mouse::apply_text_drop(&state, &drag, true));
assert_eq!(handle.to_plain_text(), "Hello world");
}
#[test]
fn text_dropped_before_its_own_range_lands_at_the_caret() {
let (state, handle, _tree) = drag_fixture("Hello world");
let drag = drag_of(&state, &handle, (6, 11), 0);
assert!(super::mouse::apply_text_drop(&state, &drag, true));
assert_eq!(handle.to_plain_text(), "worldHello ");
}
#[test]
fn press_inside_the_selection_keeps_it() {
let (state, handle, mut tree) = drag_fixture("Hello world, and a good long line of prose.");
handle.select_range(0, 20);
let rect = handle.range_rect(4, 6).expect("geometry for the selection");
synth_pointer_down(
&mut tree,
rect.x + rect.width / 2.0,
rect.y + rect.height / 2.0,
);
assert!(
state.borrow().cursor.has_selection(),
"a press inside the selection must leave it standing"
);
assert!(
matches!(
state.borrow().drag_state,
super::state::DragState::PendingTextDrag { .. }
),
"and arm a possible drag of it"
);
}
#[test]
fn press_inside_the_selection_then_release_collapses_it() {
let (state, handle, mut tree) = drag_fixture("Hello world, and a good long line of prose.");
handle.select_range(0, 20);
let rect = handle.range_rect(4, 6).expect("geometry for the selection");
let (x, y) = (rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
synth_pointer_down(&mut tree, x, y);
synth_pointer_up(&mut tree, x, y);
assert!(
!state.borrow().cursor.has_selection(),
"a click that never travelled must collapse the selection"
);
assert!(matches!(
state.borrow().drag_state,
super::state::DragState::Idle
));
}
#[test]
fn drop_caret_shows_without_focus() {
let (state, handle, _tree) = drag_fixture("Hello world, and a good long line of prose.");
assert!(
!state.borrow().has_focus,
"fixture precondition: the editor is unfocused"
);
let rect = handle.range_rect(6, 11).expect("geometry for a word");
let landed = super::mouse::move_caret_for_drag(
&state,
Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0),
);
assert!(landed, "the drag is over text");
assert!(
state.borrow().drop_caret,
"an unfocused editor under a drag must still show where the drop lands"
);
super::mouse::clear_drop_caret(&state);
assert!(
!state.borrow().drop_caret,
"and stop showing it once the drag leaves, rather than burning it in"
);
}
fn forward_only_editor(text: &str) -> (TextDocument, crate::rich_text::EditorHandle, WidgetTree) {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.set_command_filter(super::policy::CommandFilter::ForwardOnly);
(doc, handle, tree)
}
#[test]
fn forward_only_swallows_backspace_and_delete() {
use teksilo_core::event::{Key, Modifiers};
let (doc, handle, mut tree) = forward_only_editor("Hemingway");
handle.select_range(9, 9);
press_key(&mut tree, Key::Backspace, Modifiers::NONE);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"Hemingway",
"backspace must not remove a character"
);
handle.select_range(0, 0);
press_key(&mut tree, Key::Delete, Modifiers::NONE);
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "Hemingway");
handle.select_range(9, 9);
press_key(&mut tree, Key::Backspace, Modifiers::CTRL);
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "Hemingway");
}
#[test]
fn forward_only_typing_over_a_selection_appends_instead_of_replacing() {
let (doc, handle, mut tree) = forward_only_editor("keep this");
handle.select_range(0, 9);
assert!(
handle.has_selection().get(),
"precondition: the whole line is selected"
);
press_char(&mut tree, '!');
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"keep this!",
"the selected passage survives and the typed character is appended after it"
);
}
#[test]
fn ordinary_editing_still_types_over_a_selection() {
let doc = TextDocument::new();
doc.set_plain_text("keep this").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _ = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.select_range(0, 9);
press_char(&mut tree, '!');
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"!",
"an unrestricted editor replaces the selection, as every editor does"
);
}
#[test]
fn forward_only_ime_composition_does_not_eat_a_selection() {
use teksilo_core::event::WidgetEvent;
let (doc, handle, mut tree) = forward_only_editor("garder");
handle.select_range(0, 6);
tree.dispatch_event(WidgetEvent::ImeComposition {
text: "n".to_string(),
cursor: None,
});
tree.tick_animations(std::time::Duration::from_millis(16));
tree.dispatch_event(WidgetEvent::ImeCommit {
text: "你".to_string(),
});
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"garder你",
"composition appends after the selection instead of replacing it"
);
}
#[test]
fn forward_only_undo_is_blocked_but_history_survives_the_mode() {
use teksilo_core::event::{Key, Modifiers};
let (doc, handle, mut tree) = forward_only_editor("start");
handle.select_range(5, 5);
press_char(&mut tree, '!');
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "start!");
press_key(&mut tree, Key::Z, Modifiers::CTRL);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"start!",
"undo is refused while the mode is on"
);
handle.set_command_filter(super::policy::CommandFilter::All);
press_key(&mut tree, Key::Z, Modifiers::CTRL);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"start",
"and works again as soon as it is off — the history was kept, not dropped"
);
}
#[test]
fn the_filter_swap_takes_effect_without_rebuilding_the_widget() {
use teksilo_core::event::{Key, Modifiers};
let (doc, handle, mut tree) = forward_only_editor("abc");
handle.select_range(3, 3);
press_key(&mut tree, Key::Backspace, Modifiers::NONE);
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "abc");
handle.set_command_filter(super::policy::CommandFilter::All);
press_key(&mut tree, Key::Backspace, Modifiers::NONE);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"ab",
"the same mounted editor honours the new filter on the very next key"
);
}
#[test]
fn setting_a_link_over_a_selection_lands_on_the_run() {
let doc = TextDocument::new();
doc.set_plain_text("Read the manual").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(9, 15);
handle.set_link("https://example.com");
handle.select_range(9, 15);
assert_eq!(
handle.caret_char_format().anchor_href.as_deref(),
Some("https://example.com")
);
assert!(handle.is_link());
}
#[test]
fn a_link_survives_being_written_out_as_djot() {
let doc = TextDocument::new();
doc.set_plain_text("Read the manual").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(9, 15);
handle.set_link("https://example.com");
assert_eq!(
handle.to_djot().trim(),
"Read the [manual](https://example.com)"
);
}
#[test]
fn clearing_a_link_leaves_its_text_behind() {
let doc = TextDocument::new();
doc.set_djot("Read the [manual](https://example.com)")
.unwrap()
.wait()
.unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(11, 11);
let extent = handle
.link_at_caret()
.expect("the caret is inside the link");
handle.select_range(extent.start, extent.end);
handle.clear_link();
assert_eq!(handle.to_djot().trim(), "Read the manual");
}
#[test]
fn link_at_caret_reports_the_whole_link_not_the_run_under_the_caret() {
let doc = TextDocument::new();
doc.set_djot("[a _stormy_ night](https://example.com)")
.unwrap()
.wait()
.unwrap();
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
handle.select_range(5, 5);
let extent = handle.link_at_caret().expect("caret is inside the link");
assert_eq!(extent.start, 0);
assert_eq!(extent.end, 14);
assert_eq!(extent.href, "https://example.com");
}
#[test]
fn a_plain_click_on_a_link_places_the_caret_and_does_not_follow_it() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_djot("[a fairly long link label](https://example.com)")
.unwrap()
.wait()
.unwrap();
let followed = Rc::new(Cell::new(0u32));
let probe = followed.clone();
let editor = RichTextEditor::editor(doc.clone())
.on_link_activated(move |_href, _ctx| probe.set(probe.get() + 1));
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
focus_editor(&mut tree, id);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(60.0, 20.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(followed.get(), 0, "a plain click must not follow the link");
assert!(
handle.cursor_position() > 0,
"a plain click must put the caret in the link's text"
);
}
#[test]
fn ctrl_click_on_a_link_follows_it() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let doc = TextDocument::new();
doc.set_djot("[a fairly long link label](https://example.com)")
.unwrap()
.wait()
.unwrap();
let seen = Rc::new(std::cell::RefCell::new(String::new()));
let probe = seen.clone();
let editor = RichTextEditor::editor(doc.clone())
.on_link_activated(move |href, _ctx| *probe.borrow_mut() = href.to_string());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
focus_editor(&mut tree, id);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(60.0, 20.0),
button: PointerButton::Primary,
modifiers: Modifiers::CTRL,
});
assert_eq!(seen.borrow().as_str(), "https://example.com");
}
#[test]
fn editor_on_change_fires_for_a_format_only_edit() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let handle = editor.handle();
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0);
handle.select_range(0, 3);
handle.set_bold(true);
for _ in 0..3 {
tick_once(&mut tree);
}
assert!(
fired.get() > 0,
"a formatting edit is the writer's work and must count as a change"
);
}
#[test]
fn editor_on_change_stays_silent_for_a_load_that_carries_formatting() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let (editor, fired) = with_on_change_probe(RichTextEditor::editor(doc.clone()));
let mut tree = WidgetTree::new();
let _id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
for _ in 0..2 {
tick_once(&mut tree);
}
fired.set(0);
doc.set_djot("a *bold* word and [a link](https://example.com)")
.unwrap()
.wait()
.unwrap();
for _ in 0..3 {
tick_once(&mut tree);
}
assert_eq!(
fired.get(),
0,
"loading a formatted document must not look like an edit"
);
}
use super::EditSource;
#[derive(Clone, Default)]
struct Arrivals(std::rc::Rc<std::cell::RefCell<Vec<(EditSource, usize)>>>);
impl Arrivals {
fn seen(&self) -> Vec<(EditSource, usize)> {
self.0.borrow().clone()
}
fn chars_from(&self, source: EditSource) -> usize {
self.0
.borrow()
.iter()
.filter(|(s, _)| *s == source)
.map(|(_, n)| n)
.sum()
}
}
#[test]
fn typing_reports_the_characters_that_were_typed() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let arrivals = Arrivals::default();
let editor = RichTextEditor::editor(doc.clone()).on_text_inserted({
let arrivals = arrivals.clone();
move |source, chars| arrivals.0.borrow_mut().push((source, chars))
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
for ch in [' ', 'd', 'e', 'f'] {
press_char(&mut tree, ch);
}
for _ in 0..3 {
tick_once(&mut tree);
}
assert_eq!(
arrivals.seen(),
vec![(EditSource::Keyboard, 4)],
"one report per flushed batch, counting the characters in it"
);
}
#[test]
fn typing_over_a_selection_counts_what_was_typed_not_the_net_change() {
let doc = TextDocument::new();
doc.set_plain_text("abcdefghij").unwrap();
let arrivals = Arrivals::default();
let editor = RichTextEditor::editor(doc.clone()).on_text_inserted({
let arrivals = arrivals.clone();
move |source, chars| arrivals.0.borrow_mut().push((source, chars))
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::A,
teksilo_core::event::Modifiers::CTRL,
);
for ch in ['h', 'i'] {
press_char(&mut tree, ch);
}
for _ in 0..3 {
tick_once(&mut tree);
}
assert_eq!(
doc.to_plain_text().unwrap_or_default(),
"hi",
"ten characters were replaced by two"
);
assert_eq!(
arrivals.chars_from(EditSource::Keyboard),
2,
"two characters were written; the document is eight shorter, which is a \
different question and not the one being asked"
);
}
#[test]
fn a_programmatic_load_reports_nothing() {
let doc = TextDocument::new();
let arrivals = Arrivals::default();
let editor = RichTextEditor::editor(doc.clone()).on_text_inserted({
let arrivals = arrivals.clone();
move |source, chars| arrivals.0.borrow_mut().push((source, chars))
});
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
doc.set_plain_text("a whole manuscript, loaded from a file")
.unwrap();
for _ in 0..3 {
tick_once(&mut tree);
}
assert!(
arrivals.seen().is_empty(),
"opening a document is not somebody writing in it: {:?}",
arrivals.seen()
);
}
#[test]
fn an_editor_with_no_callback_is_undisturbed() {
let doc = TextDocument::new();
doc.set_plain_text("abc").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(
&mut tree,
teksilo_core::event::Key::End,
teksilo_core::event::Modifiers::CTRL,
);
for ch in ['d', 'e', 'f'] {
press_char(&mut tree, ch);
}
for _ in 0..3 {
tick_once(&mut tree);
}
assert_eq!(doc.to_plain_text().unwrap_or_default(), "abcdef");
}
fn link_only_doc() -> TextDocument {
let doc = TextDocument::new();
doc.set_html(r#"<p><a href="https://example.com/x">linklinklink</a></p>"#)
.unwrap();
doc
}
fn hrefs_after_click(
editor: RichTextEditor,
modifiers: teksilo_core::event::Modifiers,
) -> Vec<String> {
use std::cell::RefCell;
use std::rc::Rc;
let seen = Rc::new(RefCell::new(Vec::<String>::new()));
let seen_clone = seen.clone();
let editor = editor
.content_padding(0.0)
.on_link_activated(move |href, _ctx| {
seen_clone.borrow_mut().push(href.to_string());
});
let mut tree = WidgetTree::new();
let _ = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
position: Point::new(2.0, 2.0),
button: teksilo_core::event::PointerButton::Primary,
modifiers,
});
seen.borrow().clone()
}
#[test]
fn read_only_follows_a_link_on_a_plain_click() {
let hrefs = hrefs_after_click(
RichTextEditor::read_only(link_only_doc()),
teksilo_core::event::Modifiers::NONE,
);
assert_eq!(
hrefs,
vec!["https://example.com/x".to_string()],
"a read-only viewer must follow a link on an ordinary click — there is no caret to place, \
so a link that ignores a plain click reads as broken"
);
}
#[test]
fn an_editor_ignores_a_plain_click_on_a_link() {
let hrefs = hrefs_after_click(
RichTextEditor::editor(link_only_doc()),
teksilo_core::event::Modifiers::NONE,
);
assert!(
hrefs.is_empty(),
"an editable surface must NOT follow a link on a plain click: that click places the caret, \
and intercepting it leaves the text inside a link unreachable by pointer — got {hrefs:?}"
);
}
#[test]
fn an_editor_follows_a_link_on_ctrl_click() {
let hrefs = hrefs_after_click(
RichTextEditor::editor(link_only_doc()),
teksilo_core::event::Modifiers::CTRL,
);
assert_eq!(
hrefs,
vec!["https://example.com/x".to_string()],
"Ctrl(Cmd)+click remains the editor's way to follow a link"
);
}
#[test]
fn read_only_still_follows_a_link_on_ctrl_click() {
let hrefs = hrefs_after_click(
RichTextEditor::read_only(link_only_doc()),
teksilo_core::event::Modifiers::CTRL,
);
assert_eq!(
hrefs,
vec!["https://example.com/x".to_string()],
"the read-only rule must widen the gate, not replace it"
);
}
#[cfg(test)]
mod regression_vertical {
use super::*;
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
const W: f32 = 400.0;
const H: f32 = 130.0;
fn settle(tree: &mut WidgetTree) {
for _ in 0..3 {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(W, H));
}
let _ = tree.render();
}
fn press(tree: &mut WidgetTree, key: Key) {
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::NONE,
text: None,
});
settle(tree);
}
struct Fixture {
tree: WidgetTree,
handle: crate::rich_text::EditorHandle,
state: crate::rich_text::SharedState,
scroll_y: teksilo_core::signal::Signal<f32>,
}
fn fixture(text: &str) -> Fixture {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let state = editor.state_handle();
let scroll_y = state.borrow().scroll_y.clone();
{
let mut s = state.borrow_mut();
s.viewport_width = 380.0;
s.viewport_height = 120.0;
s.engine.set_viewport(380.0, 120.0);
s.needs_full_layout = true;
}
let editor_id = tree.add(editor);
let _box_id = tree.add(
crate::primitives::FixedSize::new()
.width(380.0)
.height(120.0)
.child_id(editor_id),
);
tree.layout(SizeProposal::exact(W, H));
tree.focus(editor_id);
settle(&mut tree);
Fixture {
tree,
handle,
state,
scroll_y,
}
}
fn numbered(n: usize) -> String {
(0..n)
.map(|i| format!("line {i:02} aaa"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn arrow_up_advances_on_every_press_in_a_scrolled_editor() {
let mut f = fixture(&numbered(60));
f.handle.select_range(0, 0);
settle(&mut f.tree);
for _ in 0..40 {
press(&mut f.tree, Key::ArrowDown);
}
let mut trace = Vec::new();
for _ in 0..40 {
press(&mut f.tree, Key::ArrowUp);
trace.push(f.handle.cursor_position());
}
let stalls: Vec<usize> = trace
.windows(2)
.enumerate()
.filter(|(_, w)| w[0] == w[1])
.map(|(i, _)| i)
.collect();
assert!(
stalls.is_empty(),
"ArrowUp stalled at steps {stalls:?}; trace = {trace:?}"
);
assert_eq!(
f.handle.cursor_position(),
0,
"40 downs then 40 ups must return to the top (scroll_y = {})",
f.scroll_y.get()
);
}
#[test]
fn arrow_down_works_after_scrolling_the_caret_out_of_view() {
let mut f = fixture(&numbered(60));
f.handle.select_range(0, 0);
settle(&mut f.tree);
f.scroll_y.set(600.0);
settle(&mut f.tree);
let before = f.handle.cursor_position();
press(&mut f.tree, Key::ArrowDown);
assert!(
f.handle.cursor_position() > before,
"ArrowDown must move the caret even when the caret has been \
scrolled out of view (stayed at {before})"
);
}
#[test]
fn caret_window_rect_matches_the_painted_caret() {
let mut f = fixture(&numbered(60));
f.handle.select_range(0, 0);
settle(&mut f.tree);
f.scroll_y.set(400.0);
settle(&mut f.tree);
let st = f.state.borrow();
let win = crate::rich_text::keyboard::caret_window_rect(&st).expect("focused, laid out");
let expected = st.viewport_origin.y - f.scroll_y.get();
assert!(
(win.y - expected).abs() < 1.0,
"caret_window_rect reported y = {} but the caret paints at {} \
(scroll_y = {})",
win.y,
expected,
f.scroll_y.get()
);
}
#[test]
fn arrow_down_keeps_the_caret_inside_the_viewport_on_every_press() {
let mut f = fixture(&numbered(60));
f.handle.select_range(0, 0);
settle(&mut f.tree);
for step in 0..12 {
press(&mut f.tree, Key::ArrowDown);
let (caret, vh, scroll) = {
let st = f.state.borrow();
(
st.engine
.caret_rect(st.cursor.position(), st.cursor_affinity),
st.viewport_height,
f.scroll_y.get(),
)
};
assert!(
caret[1] >= 0.0 && caret[1] + caret[3] <= vh + 0.5,
"press {step}: after ArrowDown the caret spans screen y \
{}..{} but the viewport is 0..{vh} (scroll_y = {scroll}) — \
the scroll correction is a keystroke behind",
caret[1],
caret[1] + caret[3]
);
}
}
#[test]
fn range_content_rect_does_not_move_when_the_editor_scrolls() {
let mut f = fixture(&numbered(60));
settle(&mut f.tree);
let at_top = {
let st = f.state.borrow();
crate::rich_text::keyboard::range_content_rect(&st, 0, 5).expect("laid out")
};
f.scroll_y.set(400.0);
settle(&mut f.tree);
let scrolled = {
let st = f.state.borrow();
crate::rich_text::keyboard::range_content_rect(&st, 0, 5).expect("laid out")
};
assert!(
(at_top.y - scrolled.y).abs() < 0.5,
"content-space y for offsets 0..5 moved from {} to {} when the \
editor scrolled — an overview strip would draw the mark in the \
wrong place",
at_top.y,
scrolled.y
);
}
}
#[cfg(test)]
mod regression_clipboard_internal {
use super::*;
use teksilo_core::event::{Key, Modifiers};
use teksilo_text::text_document::{MoveMode, TextFormat};
#[derive(Default)]
struct PlainOnly(std::rc::Rc<std::cell::RefCell<String>>);
impl teksilo_platform::clipboard::ClipboardBackend for PlainOnly {
fn get_text(&mut self) -> Result<String, String> {
Ok(self.0.borrow().clone())
}
fn set_text(&mut self, text: &str) -> Result<(), String> {
*self.0.borrow_mut() = text.to_string();
Ok(())
}
}
fn install<B: teksilo_platform::clipboard::ClipboardBackend + 'static>(
tree: &mut WidgetTree,
backend: B,
) {
use std::any::TypeId;
use std::collections::HashMap;
use teksilo_core::event_source::TreeAppContext;
use teksilo_platform::clipboard::ClipboardHandle;
let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
registry.insert(
TypeId::of::<ClipboardHandle>(),
Box::new(ClipboardHandle::new(backend)),
);
tree.set_app_context(std::rc::Rc::new(
TreeAppContext::empty().with_app_state(registry),
));
}
fn is_bold_at(doc: &TextDocument, pos: usize) -> Option<bool> {
let probe = doc.cursor();
probe.set_position(pos, MoveMode::MoveAnchor);
probe.char_format().unwrap_or_default().font_bold
}
#[test]
fn a_multiline_paste_undoes_in_one_step() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let cb = ctx_with_memory_clipboard(&mut tree);
cb.set_text("a\nb\nc\nd").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(&mut tree, Key::V, Modifiers::CTRL);
tick_past_debounce(&mut tree);
assert_eq!(doc.to_plain_text().unwrap_or_default(), "a\nb\nc\nd");
press_key(&mut tree, Key::Z, Modifiers::CTRL);
tick_past_debounce(&mut tree);
assert_eq!(
doc.to_plain_text().unwrap_or_default().trim(),
"",
"one undo must remove the whole pasted block"
);
}
#[test]
fn a_self_paste_keeps_formatting_without_an_html_payload() {
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let c = doc.cursor();
c.set_position(0, MoveMode::MoveAnchor);
c.set_position(5, MoveMode::KeepAnchor);
let _ = c.set_char_format(&TextFormat {
font_bold: Some(true),
..Default::default()
});
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
install(&mut tree, PlainOnly::default());
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.select_range(0, 5);
press_key(&mut tree, Key::C, Modifiers::CTRL);
press_key(&mut tree, Key::End, Modifiers::CTRL);
press_key(&mut tree, Key::V, Modifiers::CTRL);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
let pasted = plain.rfind("hello").expect("the pasted copy");
assert_eq!(
is_bold_at(&doc, pasted),
Some(true),
"an intra-app copy/paste must keep its formatting; got {plain:?}"
);
}
#[test]
fn paste_unformatted_falls_back_to_the_html_payload() {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let cb = ctx_with_memory_clipboard(&mut tree);
cb.set_html("<p>rich</p>", "").unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(&mut tree, Key::V, Modifiers::CTRL | Modifiers::SHIFT);
tick_past_debounce(&mut tree);
assert!(
!doc.to_plain_text().unwrap_or_default().is_empty(),
"paste-unformatted inserted nothing from an html-only clipboard, \
yet can_paste() reports true"
);
}
}
#[cfg(test)]
mod regression_clipboard_external {
use super::*;
use teksilo_core::event::{Key, Modifiers};
fn paste_html(html: &str, plain: &str) -> TextDocument {
let doc = TextDocument::new();
doc.set_plain_text("").unwrap();
let editor = RichTextEditor::editor(doc.clone());
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
clipboard.set_html(html, plain).unwrap();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
press_key(&mut tree, Key::V, Modifiers::CTRL);
tick_past_debounce(&mut tree);
doc
}
#[test]
fn a_style_block_does_not_paste_as_body_text() {
let doc = paste_html(
"<html><head><meta charset=\"utf-8\"><style type=\"text/css\">\
p.c1 { color: #ff0000; font-weight: 700 }\np.c2 { margin: 0 }\
</style></head><body><!--StartFragment--><p class=\"c1\">Hello</p>\
<p class=\"c2\">World</p><!--EndFragment--></body></html>",
"Hello\nWorld",
);
let got = doc.to_plain_text().unwrap_or_default();
assert!(got.contains("Hello") && got.contains("World"), "{got:?}");
assert!(
!got.contains("color:") && !got.contains("font-weight"),
"the <style> CSS was inserted as literal text: {got:?}"
);
}
#[test]
fn the_self_copy_marker_survives_the_macos_pasteboard_wrapping() {
use teksilo_text::text_document::{MoveMode, TextFormat};
let doc = TextDocument::new();
doc.set_plain_text("hello world").unwrap();
let sized = doc.cursor();
sized.set_position(0, MoveMode::MoveAnchor);
sized.set_position(5, MoveMode::KeepAnchor);
let _ = sized.set_char_format(&TextFormat {
font_point_size: Some(33),
..Default::default()
});
let editor = RichTextEditor::editor(doc.clone());
let handle = editor.handle();
let mut tree = WidgetTree::new();
let clipboard = ctx_with_memory_clipboard(&mut tree);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
focus_editor(&mut tree, id);
handle.select_range(0, 5);
press_key(&mut tree, Key::C, Modifiers::CTRL);
let written = clipboard.get_html().expect("copy wrote an html payload");
clipboard
.set_html(
&format!(
"<html><head><meta http-equiv=\"content-type\" \
content=\"text/html; charset=utf-8\"></head><body>{written}</body></html>"
),
"hello",
)
.unwrap();
press_key(&mut tree, Key::End, Modifiers::CTRL);
press_key(&mut tree, Key::V, Modifiers::CTRL);
tick_past_debounce(&mut tree);
let plain = doc.to_plain_text().unwrap_or_default();
let pasted = plain.rfind("hello").expect("the pasted copy");
let probe = doc.cursor();
probe.set_position(pasted, MoveMode::MoveAnchor);
assert_eq!(
probe.char_format().unwrap_or_default().font_point_size,
Some(33),
"the wrapped payload must still be recognised as this editor's own \
copy, so the stored fragment is reinserted rather than the HTML \
re-parsed; got {plain:?}"
);
}
#[test]
fn superscript_survives_an_html_paste() {
use teksilo_text::text_document::MoveMode;
let doc = paste_html("<p>x<sup>2</sup></p>", "x2");
let plain = doc.to_plain_text().unwrap_or_default();
let two = plain.rfind('2').expect("the exponent");
let probe = doc.cursor();
probe.set_position(two, MoveMode::MoveAnchor);
let fmt = probe.char_format().unwrap_or_default();
assert_ne!(
fmt.vertical_alignment,
Some(teksilo_text::text_document::CharVerticalAlignment::Normal),
"<sup> must import as superscript, got {:?}",
fmt.vertical_alignment
);
assert!(
fmt.vertical_alignment.is_some(),
"<sup> must import as superscript, got None"
);
}
}
#[cfg(test)]
mod regression_block_position_desync {
use super::*;
use teksilo_text::text_document::MoveMode;
fn desynced_blocks(doc: &TextDocument, state: &crate::rich_text::SharedState) -> Vec<String> {
let st = state.borrow();
let mut bad = Vec::new();
for b in doc.blocks() {
if st.engine.is_block_in_table(b.id()) {
continue;
}
let pos = b.position();
let r = st
.engine
.caret_rect(pos, teksilo_text::CursorAffinity::Downstream);
let hit = st
.engine
.hit_test(0.5, r[1] + r[3] * 0.5)
.map(|h| h.position);
if hit != Some(pos) {
bad.push(format!(
"{:?} starts at {pos} but its left edge hit-tests to {hit:?}",
b.text().chars().take(20).collect::<String>()
));
}
}
bad
}
fn editor_with(text: &str) -> (TextDocument, WidgetTree, crate::rich_text::SharedState) {
let doc = TextDocument::new();
doc.set_plain_text(text).unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(420.0, 400.0));
focus_editor(&mut tree, id);
tick_past_debounce(&mut tree);
let _ = tree.render();
(doc, tree, state)
}
fn insert_at(doc: &TextDocument, at: usize, text: &str) {
let c = doc.cursor_at(at);
c.set_position(at, MoveMode::MoveAnchor);
let _ = c.insert_text(text);
}
#[test]
fn two_edits_in_one_frame_keep_the_layout_and_the_document_agreeing() {
let (doc, mut tree, state) =
editor_with("Premier paragraphe ici.\nDeuxieme paragraphe ici.\nTroisieme ici.");
assert!(
desynced_blocks(&doc, &state).is_empty(),
"precondition: a freshly laid-out document agrees with itself"
);
let blocks = doc.blocks();
let (first, third) = (blocks[0].position(), blocks[2].position());
insert_at(&doc, first, "AAAA");
insert_at(&doc, third + 4, "BB");
tick_past_debounce(&mut tree);
let _ = tree.render();
let bad = desynced_blocks(&doc, &state);
assert!(bad.is_empty(), "after two edits in one frame: {bad:?}");
}
#[test]
fn two_edits_at_one_position_still_relayout_incrementally() {
let (doc, mut tree, state) =
editor_with("Premier paragraphe ici.\nDeuxieme paragraphe ici.");
let at = doc.blocks()[0].position();
insert_at(&doc, at, "AA");
insert_at(&doc, at, "BB");
tick_past_debounce(&mut tree);
let _ = tree.render();
assert_eq!(
doc.to_plain_text().unwrap(),
"BBAAPremier paragraphe ici.\nDeuxieme paragraphe ici."
);
assert!(
!state.borrow().needs_full_layout,
"one block, one relayout — no full layout on the typing path"
);
let bad = desynced_blocks(&doc, &state);
assert!(bad.is_empty(), "{bad:?}");
}
#[test]
fn typing_after_a_table_keeps_the_following_paragraphs_clickable() {
let (doc, mut tree, state) =
editor_with("Avant le tableau.\nApres le tableau.\nEncore un paragraphe.");
{
let c = doc.cursor_at(0);
c.set_position(0, MoveMode::MoveAnchor);
let _ = c.insert_table(2, 2);
}
tick_past_debounce(&mut tree);
let _ = tree.render();
let bad = desynced_blocks(&doc, &state);
assert!(
bad.is_empty(),
"precondition after inserting a table: {bad:?}"
);
let plain = doc.to_plain_text().unwrap();
let at = plain.find("Apres le tableau.").expect("second paragraph")
+ "Apres le tableau.".chars().count();
insert_at(&doc, at, "test");
tick_past_debounce(&mut tree);
let _ = tree.render();
let bad = desynced_blocks(&doc, &state);
assert!(
bad.is_empty(),
"after typing at a paragraph end past a table: {bad:?}"
);
}
#[test]
fn typing_a_word_at_a_paragraph_end_leaves_the_next_paragraph_clickable() {
let long = "Elle avait longtemps vecu dans une petite ville tranquille pres \
du fleuve, loin de tout, quelque part au Canada.";
let (doc, mut tree, state) = editor_with(&format!(
"{long}\nQuelqu'un s'approcha d'elle par derriere."
));
let end_of_first = long.chars().count();
insert_at(&doc, end_of_first, "test");
tick_past_debounce(&mut tree);
let _ = tree.render();
let bad = desynced_blocks(&doc, &state);
assert!(bad.is_empty(), "{bad:?}");
}
}
#[cfg(test)]
mod regression_recolor_reverts_block_position {
use super::*;
use teksilo_text::text_document::{BlockFormat, MoveMode};
const P1: &str = "Elle avait longtemps vecu dans une petite ville tranquille pres du \
fleuve, loin de tout, quelque part au Canada.";
const P2: &str = "Quelqu'un s'approcha d'elle par derriere et lui tapota l'epaule. \
Surprise, Claire se detendit en reconnaissant sa soeur, Marie.";
const P3: &str = "Le lendemain, elles repartirent ensemble vers le nord.";
fn build(
indent: Option<i32>,
) -> (
TextDocument,
WidgetTree,
crate::rich_text::SharedState,
crate::rich_text::EditorHandle,
) {
let doc = TextDocument::new();
doc.set_plain_text(&format!("{P1}\n{P2}\n{P3}")).unwrap();
if let Some(px) = indent {
for b in doc.blocks() {
let c = doc.cursor_at(b.position());
c.set_position(b.position(), MoveMode::MoveAnchor);
let _ = c.set_block_format(&BlockFormat {
text_indent: Some(px),
..Default::default()
});
}
}
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(430.0, 400.0));
focus_editor(&mut tree, id);
tick_past_debounce(&mut tree);
let _ = tree.render();
(doc, tree, state, handle)
}
fn first_line_of_block(
doc: &TextDocument,
state: &crate::rich_text::SharedState,
block_index: usize,
) -> (f32, f32) {
let mut st = state.borrow_mut();
let bid = doc.blocks()[block_index].id();
let info = st.engine.block_visual_info(bid).expect("laid out");
st.engine.with_render_frame(|frame| {
let mut in_block: Vec<&teksilo_text::TypesetterGlyphQuad> = frame
.glyphs
.iter()
.filter(|g| g.screen[1] >= info.y - 1.0 && g.screen[1] < info.y + info.height)
.collect();
in_block.sort_by(|a, b| a.screen[1].partial_cmp(&b.screen[1]).unwrap());
let top_y = in_block.first().map(|g| g.screen[1]).unwrap_or(info.y);
let first_line: Vec<_> = in_block
.iter()
.filter(|g| (g.screen[1] - top_y).abs() < 2.0)
.collect();
let min_x = first_line
.iter()
.map(|g| g.screen[0])
.fold(f32::INFINITY, f32::min);
let h = first_line.first().map(|g| g.screen[3]).unwrap_or(12.0);
(min_x, top_y + h * 0.5)
})
}
#[test]
fn typing_then_clicking_the_next_paragraph_with_the_band_on() {
let doc = TextDocument::new();
doc.set_plain_text(&format!("{P1}\n{P2}\n{P3}")).unwrap();
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(430.0, 400.0));
focus_editor(&mut tree, id);
handle.set_caret_highlight(Some(super::caret_band::band(
crate::rich_text::caret_highlight::CaretHighlightScope::Paragraph,
)));
tick_past_debounce(&mut tree);
let _ = tree.render();
let end_p1 = P1.chars().count();
handle.select_range(end_p1, end_p1);
tick_past_debounce(&mut tree);
for ch in "test".chars() {
press_char(&mut tree, ch);
}
tick_past_debounce(&mut tree);
let _ = tree.render();
let plain = doc.to_plain_text().unwrap();
let p2 = plain.find("Quelqu").expect("second paragraph");
let (x, y) = first_line_of_block(&doc, &state, 1);
synth_pointer_down(&mut tree, x + 20.0, y);
synth_pointer_up(&mut tree, x + 20.0, y);
tick_past_debounce(&mut tree);
let _ = tree.render();
let hit = state
.borrow()
.engine
.hit_test(x + 0.5, y)
.map(|h| h.position);
assert_eq!(
hit,
Some(p2),
"after typing above it and clicking into it, paragraph 2's first \
character must still be reachable (p2 starts at {p2})"
);
}
}
#[cfg(test)]
mod invariant_fuzz {
use super::*;
use teksilo_core::event::{Key, Modifiers};
fn invariant_violations(
doc: &TextDocument,
state: &crate::rich_text::SharedState,
) -> Vec<String> {
let st = state.borrow();
if !st.engine.has_full_layout() {
return Vec::new();
}
let mut bad = Vec::new();
for b in doc.blocks() {
if st.engine.is_block_in_table(b.id()) {
continue;
}
let pos = b.position();
let r = st
.engine
.caret_rect(pos, teksilo_text::CursorAffinity::Downstream);
if r[3] <= 0.0 {
continue;
}
let hit = st
.engine
.hit_test(0.5, r[1] + r[3] * 0.5)
.map(|h| h.position);
if hit != Some(pos) {
bad.push(format!(
"block {pos} {:?} -> left-edge hit {hit:?}",
b.text().chars().take(16).collect::<String>()
));
}
}
bad
}
fn stray_selection_rects(
doc: &TextDocument,
state: &crate::rich_text::SharedState,
tree: &mut WidgetTree,
handle: &crate::rich_text::EditorHandle,
) -> Vec<String> {
let mut bad = Vec::new();
let blocks = doc.blocks();
for b in &blocks {
let len = b.text().chars().count();
if len < 6 {
continue;
}
let (bid, pos) = (b.id(), b.position());
let band = {
let st = state.borrow();
if st.engine.is_block_in_table(bid) {
continue;
}
let off = st.engine.scroll_offset();
match st.engine.block_visual_info(bid) {
Some(i) => (i.y - off, i.y + i.height - off),
None => continue,
}
};
handle.select_range(pos + 1, pos + 5);
for _ in 0..3 {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(430.0, 160.0));
}
let _ = tree.render();
let (strays, left_of_text) = state.borrow_mut().engine.with_render_frame(|frame| {
let sel: Vec<&teksilo_text::DecorationRect> = frame
.decorations
.iter()
.filter(|d| {
d.kind == teksilo_text::TypesetterDecorationKind::Selection
&& d.rect[3] > 0.0
&& d.rect[2] > 0.0
})
.collect();
let strays: Vec<(i64, i64)> = sel
.iter()
.filter(|d| {
d.rect[1] + d.rect[3] * 0.5 < band.0 - 1.0
|| d.rect[1] + d.rect[3] * 0.5 > band.1 + 1.0
})
.map(|d| (d.rect[0].round() as i64, d.rect[1].round() as i64))
.collect();
let mut left_of_text = Vec::new();
for d in &sel {
let mid = d.rect[1] + d.rect[3] * 0.5;
let line_min_x = frame
.glyphs
.iter()
.filter(|g| mid >= g.screen[1] && mid <= g.screen[1] + g.screen[3])
.map(|g| g.screen[0])
.fold(f32::INFINITY, f32::min);
if line_min_x.is_finite() && d.rect[0] < line_min_x - 10.0 {
left_of_text.push((d.rect[0].round() as i64, line_min_x.round() as i64));
}
}
(strays, left_of_text)
});
if !strays.is_empty() {
bad.push(format!(
"selecting {}..{} (block band {:.0}..{:.0}) painted outside it at {strays:?}",
pos + 1,
pos + 5,
band.0,
band.1
));
}
if !left_of_text.is_empty() {
bad.push(format!(
"selecting {}..{} painted left of the text on its own line \
(rect x, first glyph x): {left_of_text:?}",
pos + 1,
pos + 5
));
}
}
bad
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self, n: usize) -> usize {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((self.0 >> 33) as usize) % n.max(1)
}
}
#[test]
fn editing_never_drifts_the_layout_from_the_document() {
for seed in 0..8u64 {
let doc = TextDocument::new();
if seed % 2 == 1 {
doc.set_plain_text("").unwrap();
let c = doc.cursor_at(0);
c.set_position(0, teksilo_text::text_document::MoveMode::MoveAnchor);
let mut html = String::new();
for i in 0..6 {
html.push_str(&format!(
"<p>Paragraphe {i}. Elle avait longtemps vecu dans une petite ville \
tranquille pres du fleuve, quelque part au Canada.</p>"
));
if i == 2 {
html.push_str(
"<blockquote><p>Une citation, dans un cadre, qui occupe \
deux lignes au moins.</p></blockquote>",
);
html.push_str("<ul><li>premier</li><li>deuxieme</li></ul>");
}
}
let _ = c.insert_html(&html);
} else {
let body: String = (0..14)
.map(|i| {
format!(
"Paragraphe {i}. Elle avait longtemps vecu dans une petite ville \
tranquille pres du fleuve, loin de tout, quelque part au Canada."
)
})
.collect::<Vec<_>>()
.join("\n");
doc.set_plain_text(&body).unwrap();
for b in doc.blocks() {
let c = doc.cursor_at(b.position());
c.set_position(
b.position(),
teksilo_text::text_document::MoveMode::MoveAnchor,
);
let _ = c.set_block_format(&teksilo_text::text_document::BlockFormat {
text_indent: Some(32),
..Default::default()
});
}
}
let editor = RichTextEditor::editor(doc.clone());
let state = editor.state_handle();
let handle = editor.handle();
let scroll_y = state.borrow().scroll_y.clone();
{
let mut st = state.borrow_mut();
st.viewport_width = 410.0;
st.viewport_height = 140.0;
st.engine.set_viewport(410.0, 140.0);
st.needs_full_layout = true;
}
let mut tree = WidgetTree::new();
let id = tree.add(editor);
let _box = tree.add(
crate::primitives::FixedSize::new()
.width(410.0)
.height(140.0)
.child_id(id),
);
tree.layout(SizeProposal::exact(430.0, 160.0));
focus_editor(&mut tree, id);
tick_past_debounce(&mut tree);
let _ = tree.render();
let mut rng = Lcg(seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(12345));
let mut log: Vec<String> = Vec::new();
for step in 0..30 {
let len = doc.to_plain_text().unwrap().chars().count().max(1);
let op = rng.next(10);
let desc = match op {
0 => {
let at = rng.next(len);
handle.select_range(at, at);
for ch in "test".chars() {
press_char(&mut tree, ch);
}
format!("type \"test\" at {at}")
}
1 => {
let at = rng.next(len);
handle.select_range(at, at);
for ch in "abcd".chars() {
press_char(&mut tree, ch);
}
let y = (rng.next(6) * 17) as f32 + 4.0;
synth_pointer_down(&mut tree, 3.0, y);
synth_pointer_up(&mut tree, 3.0, y);
format!("type \"abcd\" at {at} then click y={y} before the flush")
}
2 => {
let y = (rng.next(6) * 17) as f32 + 4.0;
let x = (rng.next(20) * 12) as f32;
synth_pointer_down(&mut tree, x, y);
synth_pointer_up(&mut tree, x, y);
format!("click ({x},{y})")
}
3 => {
let at = rng.next(len);
handle.select_range(at, at);
press_key(&mut tree, Key::Enter, Modifiers::NONE);
format!("Enter at {at}")
}
4 => {
let at = rng.next(len);
handle.select_range(at, at);
press_key(&mut tree, Key::Backspace, Modifiers::NONE);
format!("Backspace at {at}")
}
5 => {
press_key(&mut tree, Key::Z, Modifiers::CTRL);
"undo".to_string()
}
6 => {
let a = rng.next(len);
let b = (a + 1 + rng.next(12)).min(len);
handle.select_range(a, b);
press_key(&mut tree, Key::Delete, Modifiers::NONE);
format!("delete range {a}..{b}")
}
7 => {
let at = rng.next(len);
handle.select_range(at, at);
press_key(&mut tree, Key::ArrowDown, Modifiers::NONE);
format!("ArrowDown from {at}")
}
9 => {
let to = (rng.next(12) * 40) as f32;
scroll_y.set(to);
format!("scroll to {to}")
}
_ => {
let a = rng.next(len);
let b = rng.next(len);
for at in [a, b] {
let c = doc.cursor_at(at);
c.set_position(at, teksilo_text::text_document::MoveMode::MoveAnchor);
let _ = c.insert_text("Z");
}
format!("two raw inserts at {a} and {b}")
}
};
log.push(desc.clone());
for _ in 0..3 {
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(430.0, 160.0));
}
let _ = tree.render();
let mut bad = invariant_violations(&doc, &state);
if step % 3 == 0 {
bad.extend(stray_selection_rects(&doc, &state, &mut tree, &handle));
}
if !bad.is_empty() {
eprintln!("seed {seed}: VIOLATION at step {step} after: {desc}");
eprintln!(" history: {log:?}");
eprintln!(" violations: {bad:?}");
eprintln!(" text: {:?}", doc.to_plain_text().unwrap());
panic!("invariant broken");
}
}
}
eprintln!("no violations across 8 seeds x 30 steps");
}
}
fn reveal_answer(tree: &mut WidgetTree, handle: &super::EditorHandle) -> bool {
use teksilo_core::window::NoopWindowOps;
let answer = Rc::new(Cell::new(false));
let sink = answer.clone();
tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
sink.set(handle.reveal_range(ctx, 0, 5));
});
answer.get()
}
#[test]
fn reveal_range_answers_false_for_a_dormant_editor() {
let doc = TextDocument::new();
doc.set_plain_text("Alpha Beta Gamma Delta Epsilon")
.unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
assert!(
reveal_answer(&mut tree, &handle),
"on screen and laid out, the editor must report that it revealed the \
range — without this the dormant assertion below could pass on an \
editor that simply never laid out"
);
tree.set_dormant(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
!reveal_answer(&mut tree, &handle),
"parked dormant, the editor must answer false: it still holds a layout, \
but nothing it scrolls is on screen, and a caller told `true` stops \
looking for the editor that is"
);
tree.activate(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
let _ = tree.render();
assert!(
reveal_answer(&mut tree, &handle),
"a re-activated editor must reveal again"
);
}
fn reveal_widget_answer(tree: &mut WidgetTree, handle: &super::EditorHandle) -> bool {
use teksilo_core::window::NoopWindowOps;
let answer = Rc::new(Cell::new(false));
let sink = answer.clone();
tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
sink.set(handle.reveal_widget(ctx));
});
answer.get()
}
#[test]
fn reveal_widget_scrolls_a_row_that_has_never_been_painted() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, TextWidget, VStack};
let doc = TextDocument::new();
doc.set_plain_text("Alpha Beta Gamma Delta Epsilon")
.unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let editor_id = tree.add(editor);
let header = tree.add(
FixedSize::new()
.width(220.0)
.height(2000.0)
.child(TextWidget::new(lit!(""))),
);
let row = tree.add(
FixedSize::new()
.width(220.0)
.height(300.0)
.child_id(editor_id),
);
let content = tree.add(VStack::new().add_child(header).add_child(row));
let page = ScrollArea::from_id(content).smooth_scrolling(false);
let page_y = page.scroll_y_signal().clone();
let _page = tree.add(page);
let sz = SizeProposal::exact(220.0, 150.0);
tree.layout(sz);
assert!(
!reveal_answer(&mut tree, &handle),
"fixture sanity: an unpainted row must have no layout to reveal a range in — \
without this the assertion below would be testing nothing"
);
assert_eq!(
page_y.get(),
0.0,
"fixture sanity: the page must start at the top"
);
assert!(
reveal_widget_answer(&mut tree, &handle),
"a built, on-screen-eligible editor must accept a widget reveal even with no \
text layout — that is the only thing that can bring the row on screen"
);
tree.layout(sz);
assert!(
page_y.get() > 0.0,
"the page must have scrolled down to the row; still at {}",
page_y.get()
);
}
#[test]
fn reveal_widget_refuses_from_a_dormant_editor_and_before_the_first_build() {
let doc = TextDocument::new();
doc.set_plain_text("Alpha Beta Gamma").unwrap();
let editor = RichTextEditor::editor(doc);
let handle = editor.handle();
let mut tree = WidgetTree::new();
assert!(
!reveal_widget_answer(&mut tree, &handle),
"before its first build the editor is not mounted, so there is nothing to \
scroll to"
);
let id = tree.add(editor);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
reveal_widget_answer(&mut tree, &handle),
"fixture sanity: built and active, the editor must accept"
);
tree.set_dormant(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
!reveal_widget_answer(&mut tree, &handle),
"parked dormant, the editor must refuse: nothing it is inside is on screen"
);
tree.activate(id);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
reveal_widget_answer(&mut tree, &handle),
"and it must come back when the tab does"
);
}