use super::model::{self, Caret, EditOutcome, History, RowMap, SubmitPolicy};
use super::*;
use crate::base::{Point, Size};
use crate::theme::default_theme;
use crate::ui::{Key, Mods, UiEvent, UiTree};
use crate::widgets::itest_util::{key, key_mod, mount_widget, render, type_str};
#[test]
fn rowmap_tiles_every_byte_exactly_once() {
let cases = [
"",
"hello",
"hello world next",
"a\n\nb",
"ab\n",
"aa b",
"abcdefgh",
"ๅๅธๅ่ฟ็จๅ ๅฟซ",
"x๐๐ฝy\nsecond line with words",
"tab\there",
"๐จ\u{200D}๐ฉ\u{200D}๐ง\u{200D}๐ฆ family",
];
for text in cases {
for width in [1, 3, 4, 11, 80] {
let map = RowMap::build(text, width);
assert!(!map.rows.is_empty(), "{text:?}@{width}: at least one row");
let mut cursor = 0usize;
for row in &map.rows {
assert_eq!(row.start, cursor, "{text:?}@{width}: rows must tile");
assert!(row.start <= row.text_end && row.text_end <= row.end);
cursor = row.end;
}
assert_eq!(cursor, text.len(), "{text:?}@{width}: full coverage");
}
}
}
#[test]
fn rowmap_soft_wrap_matches_editor_expectations() {
let map = RowMap::build("hello world next", 11);
let rows: Vec<_> = map
.rows
.iter()
.map(|r| &"hello world next"[r.start..r.text_end])
.collect();
assert_eq!(rows, vec!["hello world ", "next"]);
let map = RowMap::build("a\n\nb", 10);
assert_eq!(map.len(), 3);
let map = RowMap::build("ab\n", 10);
assert_eq!(map.len(), 2);
assert_eq!(map.rows[1].start, 3);
let map = RowMap::build("ๅๅธๅ", 5);
let rows: Vec<_> = map
.rows
.iter()
.map(|r| &"ๅๅธๅ"[r.start..r.text_end])
.collect();
assert_eq!(rows, vec!["ๅๅธ", "ๅ"]);
let family = "๐จ\u{200D}๐ฉ\u{200D}๐ง\u{200D}๐ฆ";
let map = RowMap::build(family, 1);
assert_eq!(
&family[map.rows[0].start..map.rows[0].text_end],
family,
"cluster stays whole"
);
assert_eq!(map.len(), 2, "content row + the caret's phantom row");
assert_eq!(RowMap::build("abc", 4).len(), 1, "spare cell: no phantom");
assert_eq!(RowMap::build("abcd", 4).len(), 2, "full row: phantom");
}
fn apply(text: &mut String, c: &mut Caret, key: Key, mods: Mods, w: i32) -> EditOutcome {
model::apply_key(text, c, key, mods, w, SubmitPolicy::EnterSubmits)
}
#[test]
fn vertical_moves_keep_goal_column_over_wide_clusters() {
let mut text = String::from("abcdef\nxy\nlmnopq");
let mut c = Caret {
byte: 6, ..Caret::origin()
};
apply(&mut text, &mut c, Key::Down, Mods::NONE, 20);
assert_eq!(c.byte, 9, "short row clamps to its end (col 2)");
apply(&mut text, &mut c, Key::Down, Mods::NONE, 20);
let map = RowMap::build(&text, 20);
assert_eq!(
map.col_of(&text, c.byte, 2),
6,
"goal column 6 restored past the short row"
);
let mut text = String::from("ab๐๐ฝcd\nxyz");
let mut c = Caret {
byte: text.len(), ..Caret::origin()
};
apply(&mut text, &mut c, Key::Up, Mods::NONE, 20); assert_eq!(c.byte, 2, "mid-emoji goal snapped to the cluster start");
let mut text = String::from("ab๐๐ฝcd\nxyzw");
let mut c = Caret {
byte: text.len(), ..Caret::origin()
};
apply(&mut text, &mut c, Key::Up, Mods::NONE, 20); assert_eq!(c.byte, 10, "boundary after the emoji is its own stop");
}
#[test]
fn home_end_are_per_visual_row_and_ctrl_spans_the_document() {
let mut text = String::from("hello world next");
let mut c = Caret {
byte: 14, ..Caret::origin()
};
apply(&mut text, &mut c, Key::Home, Mods::NONE, 13);
assert_eq!(c.byte, 12, "Home goes to the visual row start");
assert!(!c.sticky);
let mut c2 = Caret {
byte: 2,
..Caret::origin()
};
apply(&mut text, &mut c2, Key::End, Mods::NONE, 13);
assert_eq!(c2.byte, 12, "End of the soft row is the boundary byte");
assert!(c2.sticky, "โฆwith end affinity (the row has a spare cell)");
let map = RowMap::build(&text, 13);
assert_eq!(
map.visual(&text, c2.byte, c2.sticky).0,
0,
"renders on row 0"
);
assert_eq!(
map.visual(&text, c2.byte, false).0,
1,
"without affinity the same byte is row 1"
);
let mut c3 = Caret {
byte: 2,
..Caret::origin()
};
apply(&mut text, &mut c3, Key::End, Mods::NONE, 11);
assert_eq!(c3.byte, 12);
assert!(!c3.sticky, "full rows have no margin cell");
apply(&mut text, &mut c2, Key::Home, Mods::CTRL, 13);
assert_eq!(c2.byte, 0);
apply(&mut text, &mut c2, Key::End, Mods::CTRL, 13);
assert_eq!(c2.byte, text.len());
}
#[test]
fn history_edges_follow_the_arrow_nav_decision_table() {
let mut text = String::new();
let mut c = Caret::origin();
assert_eq!(
apply(&mut text, &mut c, Key::Up, Mods::NONE, 10),
EditOutcome::HistoryBack
);
assert_eq!(
apply(&mut text, &mut c, Key::Down, Mods::NONE, 10),
EditOutcome::HistoryForward
);
let mut text = String::from("ab\ncd");
let mut c = Caret {
byte: 4,
..Caret::origin()
};
assert_eq!(
apply(&mut text, &mut c, Key::Up, Mods::NONE, 10),
EditOutcome::Handled { edited: false }
);
assert_eq!(
apply(&mut text, &mut c, Key::Up, Mods::NONE, 10),
EditOutcome::Handled { edited: false }
);
assert_eq!(c.byte, 0);
assert_eq!(
apply(&mut text, &mut c, Key::Up, Mods::NONE, 10),
EditOutcome::HistoryBack
);
assert_eq!(
apply(&mut text, &mut c, Key::Down, Mods::NONE, 10),
EditOutcome::Handled { edited: false }
);
assert_eq!(
apply(&mut text, &mut c, Key::Down, Mods::NONE, 10),
EditOutcome::Handled { edited: false }
);
assert_eq!(c.byte, text.len());
assert_eq!(
apply(&mut text, &mut c, Key::Down, Mods::NONE, 10),
EditOutcome::HistoryForward
);
let mut c = Caret::origin();
assert_eq!(
apply(&mut text, &mut c, Key::Down, Mods::SHIFT, 10),
EditOutcome::Handled { edited: false }
);
}
#[test]
fn history_store_preserves_the_draft_across_a_round_trip() {
let mut h = History::new(8);
h.push("one");
h.push("two");
h.push("two"); assert_eq!(h.len(), 2);
assert_eq!(h.back("draft in progress").as_deref(), Some("two"));
assert_eq!(h.back("ignored").as_deref(), Some("one"));
assert_eq!(h.back("ignored"), None, "at the oldest: stays put");
assert_eq!(h.forward().as_deref(), Some("two"));
assert_eq!(
h.forward().as_deref(),
Some("draft in progress"),
"past the newest: the draft returns"
);
assert_eq!(h.forward(), None, "not navigating anymore");
let mut h = History::new(2);
h.push("a");
h.push("b");
h.push("c");
assert_eq!(h.len(), 2);
assert_eq!(h.back("").as_deref(), Some("c"));
assert_eq!(h.back("").as_deref(), Some("b"));
assert_eq!(h.back(""), None, "'a' dropped by the cap");
}
#[test]
fn cluster_atomic_edits_over_zwj_and_combining_marks() {
let mut text = String::from("x๐จ\u{200D}๐ฉ\u{200D}๐ง\u{200D}๐ฆ");
let mut c = Caret {
byte: text.len(),
..Caret::origin()
};
apply(&mut text, &mut c, Key::Backspace, Mods::NONE, 20);
assert_eq!(text, "x");
assert_eq!(c.byte, 1);
let mut text = String::from("ab");
let mut c = Caret {
byte: 1,
..Caret::origin()
};
apply(&mut text, &mut c, Key::Char('\u{0301}'), Mods::NONE, 20);
assert_eq!(text, "a\u{0301}b");
assert_eq!(c.byte, 3, "caret after the merged cluster");
let mut text = String::from("๐๐ฝy");
let mut c = Caret::origin();
apply(&mut text, &mut c, Key::Delete, Mods::NONE, 20);
assert_eq!(text, "y");
}
#[test]
fn word_jumps_cross_line_boundaries() {
let mut text = String::from("one two\nthree");
let mut c = Caret::origin();
apply(&mut text, &mut c, Key::Right, Mods::ALT, 20);
assert_eq!(c.byte, 3);
apply(&mut text, &mut c, Key::Right, Mods::ALT, 20);
assert_eq!(c.byte, 7);
apply(&mut text, &mut c, Key::Right, Mods::ALT, 20);
assert_eq!(c.byte, 13, "jump crosses the newline");
apply(&mut text, &mut c, Key::Left, Mods::ALT, 20);
assert_eq!(c.byte, 8, "back to the start of 'three'");
}
#[test]
fn selection_spans_rows_and_replaces_on_type() {
let mut text = String::from("ab\ncd");
let mut c = Caret::origin();
apply(&mut text, &mut c, Key::Down, Mods::SHIFT, 10);
assert_eq!(c.anchor, Some(0));
assert_eq!(c.byte, 3, "extended to row 1 col 0");
apply(&mut text, &mut c, Key::Char('Z'), Mods::NONE, 10);
assert_eq!(text, "Zcd", "selection (incl. newline) replaced");
assert_eq!(c.anchor, None);
}
#[test]
fn scroll_window_follows_the_caret() {
let mut c = Caret::origin();
model::adjust_top(&mut c, 5, 10, 3);
assert_eq!(c.top, 3, "caret row 5 visible in a 3-row window");
model::adjust_top(&mut c, 0, 10, 3);
assert_eq!(c.top, 0, "moving up pulls the window up");
model::adjust_top(&mut c, 9, 4, 3);
assert_eq!(c.top, 1, "top clamps to total - visible");
}
fn composer(
size: Size,
) -> (
crate::reactive::RootScope,
UiTree,
TextAreaState,
std::rc::Rc<std::cell::RefCell<Vec<String>>>,
) {
let t = &default_theme().tokens;
let submitted: std::rc::Rc<std::cell::RefCell<Vec<String>>> = Default::default();
let s2 = submitted.clone();
let holder: std::rc::Rc<std::cell::RefCell<Option<TextAreaState>>> = Default::default();
let h2 = holder.clone();
let (root, mut tree) = mount_widget(size, move |cx| {
let state = TextAreaState::new(cx);
*h2.borrow_mut() = Some(state.clone());
let st = state.clone();
TextArea::new()
.state(&state)
.placeholder("say something")
.rows(1, 3)
.on_submit(move |v| {
s2.borrow_mut().push(v.to_string());
st.push_history(v);
st.clear();
})
.element(cx, t)
.build()
});
key(&mut tree, Key::Tab); let state = holder.borrow().clone().expect("state");
(root, tree, state, submitted)
}
#[test]
fn typing_renders_inside_the_frame_and_wraps() {
let size = Size::new(12, 4);
let (_root, mut tree, state, _) = composer(size);
type_str(&mut tree, "hello world next");
assert_eq!(state.text(), "hello world next");
crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert_eq!(canvas.cell(Point::new(1, 0)).unwrap().0, 'h');
assert!(canvas.row_text(0).contains("hello"));
assert!(
canvas.row_text(1).contains("next"),
"soft wrap: {:?}",
canvas.row_text(1)
);
let theme = default_theme();
assert_eq!(
canvas.cell(Point::new(0, 0)).unwrap().1,
theme.tokens.border_focus
);
assert_eq!(
canvas.cell(Point::new(0, 1)).unwrap().1,
theme.tokens.border_focus
);
}
#[test]
fn grows_to_cap_then_scrolls_internally() {
let size = Size::new(12, 6);
let t = &default_theme().tokens;
let (root, mut tree) = mount_widget(size, move |cx| {
let state = TextAreaState::new(cx);
crate::ui::Element::new()
.style(crate::layout::Style::column())
.child(
TextArea::new()
.state(&state)
.rows(1, 3)
.element(cx, t)
.build(),
)
.child(crate::ui::text("MARK"))
.build()
});
key(&mut tree, Key::Tab);
crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert!(canvas.row_text(1).contains("MARK"), "1 row when empty");
type_str(&mut tree, "aaaa bbbb cccc"); crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert!(canvas.row_text(2).contains("MARK"), "grew to 2 rows");
type_str(&mut tree, " dddd eeee ffff gggg");
crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert!(
canvas.row_text(3).contains("MARK"),
"capped at 3 rows: {:?}",
(0..6).map(|y| canvas.row_text(y)).collect::<Vec<_>>()
);
assert!(
!canvas.row_text(0).contains("aaaa"),
"scrolled: the first row left the window"
);
drop(root);
}
#[test]
fn enter_submits_and_alt_shift_enter_insert_newlines() {
let size = Size::new(20, 5);
let (_root, mut tree, state, submitted) = composer(size);
type_str(&mut tree, "hello");
key_mod(&mut tree, Key::Enter, Mods::ALT);
type_str(&mut tree, "world");
assert_eq!(state.text(), "hello\nworld", "Alt+Enter inserts");
key_mod(&mut tree, Key::Enter, Mods::SHIFT);
type_str(&mut tree, "!");
assert_eq!(state.text(), "hello\nworld\n!", "kitty Shift+Enter inserts");
key(&mut tree, Key::Enter);
assert_eq!(*submitted.borrow(), vec!["hello\nworld\n!".to_string()]);
assert_eq!(state.text(), "", "the submit handler cleared the buffer");
}
#[test]
fn enter_inserts_policy_never_submits() {
let t = &default_theme().tokens;
let submitted: std::rc::Rc<std::cell::RefCell<Vec<String>>> = Default::default();
let s2 = submitted.clone();
let holder: std::rc::Rc<std::cell::RefCell<Option<TextAreaState>>> = Default::default();
let h2 = holder.clone();
let (_root, mut tree) = mount_widget(Size::new(20, 5), move |cx| {
let state = TextAreaState::new(cx);
*h2.borrow_mut() = Some(state.clone());
TextArea::new()
.state(&state)
.submit_policy(SubmitPolicy::EnterInserts)
.on_submit(move |v| s2.borrow_mut().push(v.to_string()))
.element(cx, t)
.build()
});
key(&mut tree, Key::Tab);
type_str(&mut tree, "a");
key(&mut tree, Key::Enter);
type_str(&mut tree, "b");
let state = holder.borrow().clone().unwrap();
assert_eq!(state.text(), "a\nb");
assert!(submitted.borrow().is_empty());
}
#[test]
fn block_paste_inserts_whole_and_never_submits() {
let size = Size::new(20, 5);
let (_root, mut tree, state, submitted) = composer(size);
tree.dispatch(&UiEvent::Paste("line one\r\nline two\rline three".into()));
assert_eq!(
state.text(),
"line one\nline two\nline three",
"newlines kept, endings normalized"
);
assert!(submitted.borrow().is_empty(), "paste is never a submit");
crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert!(canvas.row_text(2).contains("line three"));
}
#[test]
fn history_recall_replaces_buffer_and_draft_survives() {
let size = Size::new(24, 5);
let (_root, mut tree, state, _) = composer(size);
type_str(&mut tree, "first");
key(&mut tree, Key::Enter);
type_str(&mut tree, "second");
key(&mut tree, Key::Enter);
assert_eq!(state.history_len(), 2);
type_str(&mut tree, "draft");
key(&mut tree, Key::Up);
assert_eq!(state.text(), "draft");
assert_eq!(state.caret_byte(), 0);
key(&mut tree, Key::Up);
assert_eq!(state.text(), "second");
assert_eq!(state.caret_byte(), 6, "recalled entries open at their end");
key(&mut tree, Key::Up); key(&mut tree, Key::Up); assert_eq!(state.text(), "first");
key(&mut tree, Key::Down); assert_eq!(state.text(), "second");
key(&mut tree, Key::Down);
assert_eq!(state.text(), "draft", "the in-progress draft survived");
}
#[test]
fn caret_cell_tracks_typing_and_clears_on_blur() {
let size = Size::new(20, 4);
let (_root, mut tree, state, _) = composer(size);
let first = state.caret_cell().get_untracked().expect("focused: anchor");
type_str(&mut tree, "ab");
let after = state.caret_cell().get_untracked().expect("still focused");
assert_eq!(after.y, first.y);
assert_eq!(after.x, first.x + 2, "anchor follows the caret");
tree.set_focus(None);
assert_eq!(
state.caret_cell().get_untracked(),
None,
"blur clears the anchor (owner-driven dismissal keys off this)"
);
}
#[test]
fn placeholder_disabled_and_a11y() {
let t = &default_theme().tokens;
let theme = default_theme();
let size = Size::new(20, 3);
let (_root, mut tree) = mount_widget(size, move |cx| {
TextArea::new().placeholder("say it").element(cx, t).build()
});
let canvas = render(&mut tree, size);
assert!(canvas.row_text(0).contains("say it"));
assert_eq!(
canvas.cell(Point::new(1, 0)).unwrap().1,
theme.tokens.text_faint
);
key(&mut tree, Key::Tab);
let canvas = render(&mut tree, size);
assert!(!canvas.row_text(0).contains("say it"));
type_str(&mut tree, "hi");
let snapshot = tree.accessibility_tree();
let entry = snapshot.find(crate::ui::Role::TextArea).expect("role");
assert_eq!(entry.value.as_deref(), Some("hi"));
assert_eq!(entry.label, "say it");
let t2 = &default_theme().tokens;
let holder: std::rc::Rc<std::cell::RefCell<Option<TextAreaState>>> = Default::default();
let h2 = holder.clone();
let (_root2, mut tree2) = mount_widget(size, move |cx| {
let state = TextAreaState::new(cx);
state.set_text("frozen");
*h2.borrow_mut() = Some(state.clone());
TextArea::new()
.state(&state)
.disabled(true)
.element(cx, t2)
.build()
});
key(&mut tree2, Key::Tab);
assert_eq!(tree2.focused(), None, "disabled is out of the focus order");
type_str(&mut tree2, "x");
let state = holder.borrow().clone().unwrap();
assert_eq!(state.text(), "frozen", "disabled ignores keys");
crate::reactive::flush_effects();
tree2.layout();
let canvas = render(&mut tree2, size);
assert_eq!(
canvas.cell(Point::new(1, 0)).unwrap().1,
theme.tokens.text_faint,
"disabled ink is text_faint"
);
}
#[test]
fn placeholder_while_focused_paints_beside_the_caret() {
let t = &default_theme().tokens;
let theme = default_theme();
let size = Size::new(20, 3);
let (_root, mut tree) = mount_widget(size, move |cx| {
TextArea::new()
.placeholder("say it")
.placeholder_while_focused(true)
.element(cx, t)
.build()
});
key(&mut tree, Key::Tab); let canvas = render(&mut tree, size);
let caret = canvas.cell(Point::new(1, 0)).unwrap();
assert_eq!(caret.0, ' ', "caret cell is a styled blank");
assert_eq!(caret.2, theme.tokens.cursor, "caret bg is the cursor token");
assert_eq!(canvas.cell(Point::new(2, 0)).unwrap().0, 's');
assert_eq!(
canvas.cell(Point::new(2, 0)).unwrap().1,
theme.tokens.text_faint,
"focused placeholder keeps the text_faint ink"
);
assert!(canvas.row_text(0).contains("say it"));
type_str(&mut tree, "h");
crate::reactive::flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert!(
!canvas.row_text(0).contains("say it"),
"typing hides the focused placeholder: {:?}",
canvas.row_text(0)
);
assert_eq!(canvas.cell(Point::new(1, 0)).unwrap().0, 'h');
assert_eq!(
canvas.cell(Point::new(2, 0)).unwrap().2,
theme.tokens.cursor,
"caret advanced past the typed glyph"
);
}
#[test]
fn replace_range_snaps_to_cluster_boundaries() {
let (_root, _tree, state, _) = composer(Size::new(20, 4));
state.set_text("x๐๐ฝy");
state.replace_range(3..3, "@");
assert_eq!(
state.text(),
"x๐๐ฝ@y",
"mid-cluster insert snapped to the end"
);
assert_eq!(state.caret_byte(), 10);
}