use std::time::Duration;
use proptest::prelude::*;
use smudge_core::clipboard::{Clipboard, MemoryClipboard};
use smudge_core::geometry::{Color, PixelRect, Point, Rect};
use smudge_core::input::{InputEvent, Key, Modifiers};
use smudge_core::paint::{DisplayItem, PaintOp};
use smudge_core::reactive::RwSignal;
use smudge_core::reactive::prelude::*;
use smudge_core::style::px;
use smudge_core::theme::{ColorToken, Theme};
use smudge_core::widget::panel;
use smudge_test::Harness;
use smudge_text::{Editor, TextInputExt, TextStyle, text_input};
const BLINK: Duration = Duration::from_millis(530);
const VIEWPORT: (f32, f32) = (200.0, 60.0);
fn mounted(initial: &str) -> (Harness, RwSignal<String>) {
let value = RwSignal::new(String::from(initial));
let bound = value.clone();
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
text_input(bound.clone())
.width(px(VIEWPORT.0))
.height(px(VIEWPORT.1))
});
harness.frame();
(harness, value)
}
fn focus(harness: &mut Harness) {
harness.click(Point {
x: 60.0,
y: VIEWPORT.1 / 2.0,
});
harness.settle();
}
#[test]
fn typing_then_backspace_leaves_the_rest_of_the_text() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("héllo");
assert_eq!(value.get(), "héllo", "the signal carries every typed rune");
harness.press_key(Key::Backspace);
assert_eq!(value.get(), "héll", "backspace removes one grapheme");
}
#[test]
fn control_backspace_deletes_the_word_before_the_caret() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("hello world");
let modifiers = Modifiers {
control: true,
..Modifiers::default()
};
harness.press_key_with(Key::Backspace, modifiers);
assert_eq!(value.get(), "hello ", "the word goes, the space stays");
}
#[test]
fn shift_home_selects_to_the_start_and_typing_replaces_the_selection() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("hello world");
harness.press_key_with(
Key::Home,
Modifiers {
shift: true,
..Modifiers::default()
},
);
harness.type_text("X");
assert_eq!(value.get(), "X", "the selection is gone, replaced by X");
}
#[test]
fn command_a_selects_all_and_typing_replaces() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("keep one");
harness.press_key_with(Key::Character(String::from("a")), command());
harness.type_text("X");
assert_eq!(value.get(), "X");
}
#[test]
fn undo_removes_typed_text_a_word_at_a_time_and_redo_brings_it_back() {
let (mut harness, value) = mounted("");
focus(&mut harness);
let z = || Key::Character(String::from("z"));
let redo = Modifiers {
shift: true,
..command()
};
harness.type_text("hello world");
harness.press_key_with(z(), command());
assert_eq!(value.get(), "hello", "the last word goes first");
harness.press_key_with(z(), command());
assert_eq!(value.get(), "");
harness.press_key_with(z(), command());
assert_eq!(value.get(), "", "undo past the first step does nothing");
harness.press_key_with(z(), redo);
assert_eq!(value.get(), "hello");
harness.press_key_with(z(), redo);
assert_eq!(value.get(), "hello world");
harness.press_key_with(z(), command());
harness.type_text("!");
harness.press_key_with(z(), redo);
assert_eq!(value.get(), "hello!", "an edit after an undo ends redo");
}
#[test]
fn a_word_delete_is_an_undo_step_of_its_own() {
let (mut harness, value) = mounted("");
focus(&mut harness);
let z = || Key::Character(String::from("z"));
harness.type_text("hello world");
harness.press_key_with(
Key::Backspace,
Modifiers {
control: true,
..Modifiers::default()
},
);
harness.press_key(Key::Backspace);
assert_eq!(value.get(), "hello");
harness.press_key_with(z(), command());
assert_eq!(value.get(), "hello ", "the grapheme delete undoes alone");
harness.press_key_with(z(), command());
assert_eq!(value.get(), "hello world", "then the word delete");
}
#[test]
fn committed_text_is_an_undo_step_of_its_own() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.event(InputEvent::ImeCommit {
text: String::from("世界"),
});
harness.type_text("x");
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(value.get(), "世界", "the typed character undoes alone");
}
#[test]
fn undo_puts_the_selection_back() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("hello");
harness.press_key_with(Key::Character(String::from("a")), command());
harness.type_text("X");
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(value.get(), "hello");
harness.type_text("Y");
assert_eq!(value.get(), "Y", "the whole text was selected again");
}
#[test]
fn an_outside_write_ends_the_undo_history() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("draft");
value.set(String::from("saved"));
harness.settle();
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(value.get(), "saved", "undo never brings back replaced text");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn control_y_redoes() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("word");
harness.press_key_with(Key::Character(String::from("z")), command());
harness.press_key_with(Key::Character(String::from("y")), command());
assert_eq!(value.get(), "word");
}
fn command() -> Modifiers {
#[cfg(target_os = "macos")]
{
Modifiers {
meta: true,
..Modifiers::default()
}
}
#[cfg(not(target_os = "macos"))]
{
Modifiers {
control: true,
..Modifiers::default()
}
}
}
#[test]
fn enter_and_tab_do_not_edit_and_tab_wraps_to_the_input() {
let (mut harness, value) = mounted("text");
focus(&mut harness);
harness.press_key(Key::Enter);
assert_eq!(value.get(), "text", "Enter inserts nothing");
harness.press_key(Key::Tab);
assert_eq!(value.get(), "text", "Tab inserts nothing");
assert!(
harness.app().focused().is_some(),
"Tab wraps to the input itself when it is the only focusable node"
);
}
#[test]
fn an_ime_commit_inserts_without_a_key_event() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.event(InputEvent::ImeCommit {
text: String::from("世界"),
});
assert_eq!(value.get(), "世界", "the committed text is in the signal");
}
fn is_underline(rect: &Rect) -> bool {
rect.height() <= 2.0 && rect.width() > 4.0
}
fn ime_commit(harness: &mut Harness, text: &str) {
harness.event(InputEvent::ImePreedit {
text: String::new(),
cursor: None,
});
harness.event(InputEvent::ImeCommit {
text: String::from(text),
});
}
#[test]
fn a_preedit_draws_underlined_text_and_never_touches_the_signal() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.event(InputEvent::ImePreedit {
text: String::from("こんに"),
cursor: Some((9, 9)),
});
harness.settle();
let (fills, runs) = painted(&mut harness);
assert!(
!runs.is_empty(),
"the composing text is drawn before it is committed"
);
assert!(
fills.iter().any(is_underline),
"the composing text is underlined: {fills:?}"
);
assert_eq!(
value.get(),
"",
"the preedit is not committed text, so the signal waits"
);
ime_commit(&mut harness, "今日は");
harness.settle();
assert_eq!(value.get(), "今日は", "the commit writes the signal");
let (fills, _) = painted(&mut harness);
assert!(
!fills.iter().any(is_underline),
"the underline goes once the text is committed: {fills:?}"
);
}
#[test]
fn a_commit_while_composing_takes_the_preedit_place() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.event(InputEvent::ImePreedit {
text: String::from("こんにちは"),
cursor: Some((15, 15)),
});
harness.event(InputEvent::ImeCommit {
text: String::from("今日は"),
});
harness.settle();
assert_eq!(
value.get(),
"今日は",
"the committed text replaced the preedit, it did not follow it"
);
harness.type_text("x");
harness.settle();
assert_eq!(
value.get(),
"今日はx",
"the caret sits after the committed text"
);
}
#[test]
fn a_canceled_preedit_leaves_the_text_as_it_was() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("ab");
harness.event(InputEvent::ImePreedit {
text: String::from("かんじ"),
cursor: Some((9, 9)),
});
harness.settle();
harness.event(InputEvent::ImePreedit {
text: String::new(),
cursor: None,
});
harness.settle();
assert_eq!(value.get(), "ab", "the cancel is not an edit of the value");
harness.type_text("c");
harness.settle();
assert_eq!(
value.get(),
"abc",
"the caret sits where the preedit was, and typing goes on"
);
}
#[test]
fn composing_over_a_selection_replaces_it_like_typing() {
let (mut harness, value) = mounted("hello world");
focus(&mut harness);
harness.press_key(Key::Home);
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
for _ in 0..5 {
harness.press_key_with(Key::ArrowRight, shift);
}
harness.event(InputEvent::ImePreedit {
text: String::from("こん"),
cursor: Some((6, 6)),
});
harness.settle();
assert_eq!(
value.get(),
" world",
"the composition takes the selection's place as soon as it starts"
);
ime_commit(&mut harness, "今度");
harness.settle();
assert_eq!(
value.get(),
"今度 world",
"the committed text stands where the selection was"
);
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(value.get(), " world", "undo removes the commit");
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(
value.get(),
"hello world",
"a second undo brings the selection back"
);
}
#[test]
fn a_paste_while_composing_ends_the_composition() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
harness.event(InputEvent::ImePreedit {
text: String::from("ãé"),
cursor: Some((4, 1)),
});
harness.settle();
clipboard.set(String::from("xé"));
harness.press_key_with(Key::Character(String::from("v")), command());
harness.settle();
assert_eq!(
value.get(),
"xé",
"the paste drops the preedit and inserts at its start"
);
harness.event(InputEvent::ImeCommit {
text: String::from("あ"),
});
harness.settle();
assert_eq!(
value.get(),
"xéあ",
"the paste cancelled the composition, so the commit lands at the caret"
);
}
#[test]
fn losing_the_focus_cancels_the_composition() {
let value = RwSignal::new(String::new());
let bound = value.clone();
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
panel()
.column()
.child(
text_input(bound.clone())
.width(px(VIEWPORT.0))
.height(px(30.0)),
)
.child(panel().width(px(40.0)).height(px(20.0)).focusable())
});
harness.frame();
let input = Point { x: 60.0, y: 15.0 };
let other = Point { x: 20.0, y: 45.0 };
harness.click(input);
harness.settle();
harness.type_text("ab");
harness.event(InputEvent::ImePreedit {
text: String::from("かんじ"),
cursor: Some((9, 9)),
});
harness.settle();
let (fills, _) = painted(&mut harness);
assert!(
fills.iter().any(is_underline),
"the composition is showing: {fills:?}"
);
harness.click(other);
harness.settle();
let (fills, _) = painted(&mut harness);
assert!(
!fills.iter().any(is_underline),
"the preedit goes with the focus: {fills:?}"
);
assert_eq!(
value.get(),
"ab",
"a composition that dies with the focus commits nothing"
);
harness.advance(Duration::from_secs(1));
harness.click(input);
harness.settle();
harness.type_text("c");
harness.settle();
assert_eq!(
value.get(),
"abc",
"typing after the lost composition edits the field, not a stale \
preedit"
);
}
fn mounted_with_clipboard(initial: &str) -> (Harness, RwSignal<String>, MemoryClipboard) {
let (mut harness, value) = mounted(initial);
let clipboard = MemoryClipboard::default();
harness.app().set_clipboard(Box::new(clipboard.clone()));
(harness, value, clipboard)
}
#[test]
fn copy_then_paste_duplicates_the_selection() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
harness.type_text("hello world");
harness.press_key_with(Key::Character(String::from("a")), command());
harness.press_key_with(Key::Character(String::from("c")), command());
assert_eq!(
clipboard.get().as_deref(),
Some("hello world"),
"copy puts the selected text on the clipboard"
);
harness.press_key(Key::ArrowRight);
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"hello worldhello world",
"paste inserts the copied text at the caret"
);
}
#[test]
fn cut_moves_the_selection_to_the_clipboard() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
harness.type_text("hello world");
harness.press_key_with(Key::Character(String::from("a")), command());
harness.press_key_with(Key::Character(String::from("x")), command());
assert_eq!(value.get(), "", "cut removes the selected text");
assert_eq!(
clipboard.get().as_deref(),
Some("hello world"),
"cut puts the selected text on the clipboard"
);
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(value.get(), "hello world", "paste brings it back");
}
#[test]
fn a_paste_flattens_line_breaks_into_spaces() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
clipboard.set(String::from("one\ntwo\r\nthree"));
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"one two three",
"a single line takes a multi-line paste as spaces, a CRLF pair as one"
);
clipboard.set(String::from("\r\nx\ry\nz"));
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"one two three x y z",
"every kind of line break becomes exactly one space"
);
clipboard.set(String::from("\r\n\r"));
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"one two three x y z ",
"a paste of nothing but line breaks becomes spaces"
);
}
#[test]
fn a_paste_flattens_every_mandatory_break_into_one_space() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
clipboard.set(String::from("a\u{0B}b\u{0C}c\u{85}d\u{2028}e\u{2029}f"));
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"a b c d e f",
"vertical tab, form feed, next line and the line and paragraph separators become spaces"
);
}
#[test]
fn a_paste_is_an_undo_step_of_its_own() {
let (mut harness, value, mut clipboard) = mounted_with_clipboard("");
focus(&mut harness);
harness.type_text("ab");
clipboard.set(String::from("c"));
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(value.get(), "abc");
harness.press_key_with(Key::Character(String::from("z")), command());
assert_eq!(
value.get(),
"ab",
"undo takes back the paste and not the word typed before it"
);
}
#[test]
fn without_a_clipboard_the_clipboard_keys_do_nothing() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("kept");
harness.press_key_with(Key::Character(String::from("a")), command());
harness.press_key_with(Key::Character(String::from("x")), command());
harness.press_key_with(Key::Character(String::from("v")), command());
assert_eq!(
value.get(),
"kept",
"cut and paste without a clipboard change nothing and do not panic"
);
}
#[test]
fn a_press_at_the_edges_places_the_caret_at_the_matching_end() {
let (mut harness, value) = mounted("abcdef");
harness.settle();
harness.click(Point {
x: 2.0,
y: VIEWPORT.1 / 2.0,
});
harness.settle();
harness.type_text("X");
assert_eq!(
value.get(),
"Xabcdef",
"a press near the left edge puts the caret at the start"
);
value.set(String::from("abcdef"));
harness.settle();
harness.click(Point {
x: VIEWPORT.0 - 2.0,
y: VIEWPORT.1 / 2.0,
});
harness.settle();
harness.type_text("X");
assert_eq!(
value.get(),
"abcdefX",
"a press near the right edge puts the caret at the end"
);
}
#[test]
fn a_drag_selects_and_typing_replaces_the_selection() {
let (mut harness, value) = mounted("abcdef");
focus(&mut harness);
harness.button_down(Point {
x: 2.0,
y: VIEWPORT.1 / 2.0,
});
harness.move_to(Point {
x: VIEWPORT.0 - 2.0,
y: VIEWPORT.1 / 2.0,
});
harness.button_up(Point {
x: VIEWPORT.0 - 2.0,
y: VIEWPORT.1 / 2.0,
});
harness.type_text("X");
assert_eq!(value.get(), "X", "the drag selected everything");
}
#[test]
fn a_double_click_selects_a_word() {
let (mut harness, value) = mounted("foo bar");
focus(&mut harness);
let point = Point {
x: VIEWPORT.0 - 10.0,
y: VIEWPORT.1 / 2.0,
};
harness.click(point);
harness.click(point);
harness.type_text("X");
assert_eq!(
value.get(),
"foo X",
"the double click selected the word under the pointer"
);
}
#[test]
fn clicks_too_far_apart_in_time_or_space_are_not_a_double_click() {
let (mut harness, value) = mounted("foo bar");
focus(&mut harness);
let end = Point {
x: VIEWPORT.0 - 10.0,
y: VIEWPORT.1 / 2.0,
};
harness.click(end);
harness.advance(Duration::from_millis(401));
harness.click(end);
harness.type_text("X");
assert_eq!(value.get(), "foo barX", "401 ms apart is two single clicks");
harness.advance(Duration::from_secs(1));
harness.click(end);
harness.click(Point {
x: end.x - 5.0,
..end
});
harness.type_text("Y");
assert_eq!(value.get(), "foo barXY", "5 px apart is two single clicks");
}
#[test]
fn a_click_right_after_a_drag_places_the_caret() {
let (mut harness, value) = mounted("foo bar");
focus(&mut harness);
let start = Point {
x: 2.0,
y: VIEWPORT.1 / 2.0,
};
harness.advance(Duration::from_secs(1));
harness.button_down(start);
harness.move_to(Point {
x: VIEWPORT.0 - 2.0,
..start
});
harness.button_up(Point {
x: VIEWPORT.0 - 2.0,
..start
});
harness.click(start);
harness.type_text("X");
assert_eq!(
value.get(),
"Xfoo bar",
"the click after a drag is a single click, not a double click on \
the drag's start"
);
}
#[test]
fn a_blink_frame_damages_only_the_caret() {
let (mut harness, _value) = mounted("");
focus(&mut harness);
harness.type_text("hi");
harness.settle();
let steady = harness.frame();
assert!(
steady.damage.is_empty() && steady.nodes_painted == 0,
"the caret does not move on its own: {steady:?}"
);
let blink = harness.advance(BLINK);
assert_eq!(blink.nodes_painted, 1, "only the caret node repaints");
assert_eq!(blink.damage.len(), 1, "the blink damages one rectangle");
let caret = blink.damage[0];
assert!(
caret.width <= 4,
"the caret is a pixel wide, not the whole input: {caret:?}"
);
assert!(caret.height <= 34, "the caret is one line tall: {caret:?}");
harness.assert_matches_full_repaint();
let back = harness.advance(BLINK);
assert_eq!(back.nodes_painted, 1);
assert_eq!(back.damage.len(), 1);
assert!(back.damage[0].width <= 4 && back.damage[0].height <= 34);
}
#[test]
fn an_editing_frame_repaints_the_text_and_the_blink_stays_small() {
let (mut harness, _value) = mounted("");
focus(&mut harness);
harness.type_text("a");
let report = harness.frame();
assert!(
!report.damage.is_empty(),
"typing repaints the text the frame the key arrives"
);
harness.assert_matches_full_repaint();
harness.settle();
let steady = harness.frame();
assert!(steady.damage.is_empty() && steady.nodes_painted == 0);
let blink = harness.advance(BLINK);
assert_eq!(blink.nodes_painted, 1, "the blink repaints the caret alone");
assert!(blink.damage.iter().all(is_small_caret));
}
#[test]
fn scrolling_keeps_the_caret_inside_the_input() {
let (mut harness, value) = mounted("");
focus(&mut harness);
let long = "0123456789".repeat(6);
harness.type_text(&long);
harness.settle();
assert_eq!(value.get(), long);
let blink = harness.advance(BLINK);
assert_eq!(
blink.damage.len(),
1,
"the blink still damages one rectangle"
);
let caret = blink.damage[0];
assert!(is_small_caret(&caret));
assert!(
caret.x + caret.width <= VIEWPORT.0 as u32,
"the caret stays inside the input while the text scrolls: {caret:?}"
);
}
#[test]
fn trailing_spaces_keep_the_caret_inside_the_input() {
let (mut harness, _value) = mounted("");
focus(&mut harness);
harness.type_text(&"0123456789".repeat(4));
harness.type_text(&" ".repeat(12));
harness.settle();
let (fills, _) = painted(&mut harness);
let caret = fills.last().expect("the focused input paints its caret");
assert!(
caret.x1 <= VIEWPORT.0 && caret.x0 > VIEWPORT.0 - 4.0,
"spaces past the text's ink still scroll the caret to the right \
edge: {caret:?}"
);
}
fn is_small_caret(rect: &PixelRect) -> bool {
rect.width <= 4 && rect.height <= 34
}
#[test]
fn the_placeholder_shows_while_the_text_is_empty() {
let value = RwSignal::new(String::new());
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
text_input(value.clone())
.width(px(VIEWPORT.0))
.height(px(VIEWPORT.1))
.placeholder("Type here")
});
harness.frame();
harness.settle();
harness.assert_matches_full_repaint();
harness.type_text("hi");
harness.settle();
assert!(
!harness.png().is_empty(),
"the input renders with and without its placeholder"
);
}
#[test]
fn editing_right_to_left_text_edits_the_signal() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.type_text("سلام");
assert_eq!(value.get(), "سلام");
harness.press_key(Key::Backspace);
let remaining = value.get();
assert_eq!(
remaining.chars().count(),
3,
"backspace removes one grapheme"
);
assert!(remaining.chars().eq("سلام".chars().take(3)));
}
#[test]
fn the_caret_moves_visually_in_right_to_left_text_as_parley_reports() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("سلام");
let caret_width = 1.0;
let end = editor
.cursor_geometry(caret_width)
.expect("the editor has a caret");
editor.move_right();
let after_right = editor
.cursor_geometry(caret_width)
.expect("the caret is still there");
assert!(
after_right.x0 > end.x0,
"moving right moves the caret visually right, even though it is \
moving towards the logical start: {} -> {}",
end.x0,
after_right.x0
);
editor.move_left();
editor.move_left();
let after_left = editor
.cursor_geometry(caret_width)
.expect("the caret is still there");
assert!(
after_left.x0 < after_right.x0,
"moving left moves the caret visually left: {} -> {}",
after_right.x0,
after_left.x0
);
assert!(
(after_left.x0 - end.x0).abs() < 1.0,
"the caret lands on the boundaries parley reported before: {} vs {}",
after_left.x0,
end.x0
);
}
#[test]
fn an_empty_commit_removes_the_preedit_and_keeps_the_selection() {
let value = RwSignal::new(String::from("abc"));
let editor = Editor::new(value.clone());
editor.set_style(&TextStyle::default());
editor.set_text("abc");
editor.select_all();
editor.commit("");
assert_eq!(value.get(), "abc", "committing nothing deletes nothing");
assert_eq!(editor.selection(), 0..3, "the selection stays");
editor.compose("か", None);
editor.commit("");
assert!(!editor.is_composing(), "the preedit is gone");
assert_eq!(
value.get(),
"",
"the composition took the selection's place"
);
}
#[test]
fn an_outside_write_replaces_the_text_and_moves_the_caret_to_the_end() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value.clone());
editor.set_style(&TextStyle::default());
value.set(String::from("héllo"));
editor.set_text(&value.get());
editor.insert("!");
assert_eq!(
editor.text(),
"héllo!",
"the caret sits at the end after an outside write"
);
editor.set_text("short");
assert_eq!(editor.selection(), 5..5, "the caret is at the new end");
}
#[test]
fn deleting_a_line_break_cuts_its_paragraphs_into_one() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("ab\n");
editor.move_to_start();
editor.move_right();
editor.move_right();
editor.delete();
assert_eq!(editor.text(), "ab", "the delete took the line break");
editor.delete();
assert_eq!(editor.text(), "ab", "nothing is left after the caret");
editor.move_down();
editor.insert("X");
assert_eq!(
editor.text(),
"abX",
"the caret stays at the end of the one paragraph"
);
}
#[test]
fn a_word_delete_at_a_paragraph_end_takes_the_break_and_the_next_word() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("\nab cd");
editor.move_to_start();
editor.delete_word();
assert_eq!(
editor.text(),
"cd",
"the delete reached from the empty first paragraph across \
the break and through the word and its trailing space"
);
}
#[test]
fn a_word_backspace_at_a_paragraph_start_reaches_the_previous_word() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("ab\ncd");
editor.move_to_start();
editor.move_right();
editor.move_right();
editor.move_right();
editor.backspace_word();
assert_eq!(
editor.text(),
"cd",
"the backspace reached from the start of the second line \
back through the word on the first"
);
}
#[test]
fn a_preedit_update_that_shrinks_keeps_the_paragraphs_in_bounds() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.compose("あいう", None);
editor.compose("あ", None);
editor.move_down();
assert_eq!(
editor.raw_text(),
"あ",
"the paragraphs the smaller preedit left still reach the text"
);
}
#[test]
fn a_backspace_after_an_emoji_takes_the_emoji_and_not_the_line_break() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("ab\u{1F600}\ncd");
editor.move_to_start();
editor.move_right();
editor.move_right();
editor.move_right();
editor.backspace();
assert_eq!(
editor.text(),
"ab\ncd",
"the emoji goes whole, the break after it stays"
);
}
#[test]
fn a_backspace_over_a_carriage_return_takes_the_whole_break() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("ab\r\ncd");
editor.move_to_start();
editor.move_right();
editor.move_right();
editor.move_right();
editor.backspace();
assert_eq!(
editor.text(),
"abcd",
"the carriage return and the line break it starts go together"
);
}
#[test]
fn a_preedit_of_only_a_line_break_anchors_the_ime_area() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.compose("\n", None);
let area = editor.ime_area();
assert!(
area.y1 > area.y0 && area.x1 >= area.x0,
"the area falls back to the caret when the preedit holds no glyphs"
);
}
#[test]
fn a_preedit_cursor_inside_a_character_snaps_to_a_boundary() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.compose("あいう", Some((5, 2)));
let raw = editor.raw_text();
assert_eq!(
editor.selection(),
0..3,
"the preedit cursor snapped back to the boundaries of the \
characters it was inside, of {raw:?}"
);
}
#[test]
fn an_insert_of_several_lines_leaves_the_caret_on_the_last_line() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.insert("one\ntwo");
let line = TextStyle::default().size * TextStyle::default().line_height;
let caret = editor.cursor_geometry(1.0).expect("the editor has a caret");
assert!(
caret.y0 >= line - 1.0,
"the caret sits on the second line, not the first: {caret:?}"
);
}
fn painted(harness: &mut Harness) -> (Vec<Rect>, Vec<Color>) {
let output = harness.app().frame();
let mut fills = Vec::new();
let mut glyph_colors = Vec::new();
for item in output.list.items() {
match item {
DisplayItem::Op(PaintOp::FillRect { rect, .. }) => fills.push(*rect),
DisplayItem::Op(PaintOp::Glyphs(run)) => glyph_colors.push(run.color),
_ => {}
}
}
(fills, glyph_colors)
}
#[test]
fn an_edit_moves_the_caret_in_the_frame_that_shows_the_text() {
let (mut harness, _value) = mounted("");
focus(&mut harness);
harness.type_text("ab");
harness.frame();
assert!(
!harness.woken(),
"placing the caret in the apply step asks for no second frame"
);
let next = harness.frame();
assert!(
next.nodes_painted == 0 && next.damage.is_empty(),
"the frame that painted the text also placed the caret: {next:?}"
);
}
#[test]
fn a_selection_in_scrolled_text_paints_over_its_glyphs() {
let (mut harness, _value) = mounted("");
focus(&mut harness);
harness.type_text(&"0123456789".repeat(6));
harness.settle();
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
for _ in 0..3 {
harness.press_key_with(Key::ArrowLeft, shift);
}
harness.settle();
let (fills, _) = painted(&mut harness);
let selection = fills
.iter()
.find(|rect| rect.width() > 4.0)
.expect("three selected digits paint a selection");
assert!(
selection.x0 >= 0.0 && selection.x1 <= VIEWPORT.0,
"the selection scrolls with the text it covers: {selection:?}"
);
}
#[test]
fn an_outside_write_repaints_without_running_layout() {
let (mut harness, value) = mounted("one");
harness.settle();
value.set(String::from("two"));
let report = harness.frame();
assert!(
!report.layout_ran,
"the input's size does not hang on its text"
);
assert!(report.nodes_painted > 0, "the new text is painted");
}
#[test]
fn the_placeholder_paints_in_the_muted_text_color() {
let value = RwSignal::new(String::new());
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
text_input(value.clone())
.width(px(VIEWPORT.0))
.height(px(VIEWPORT.1))
.placeholder("Type here")
});
harness.settle();
let (_, colors) = painted(&mut harness);
let muted = Theme::light().color(ColorToken::TEXT_MUTED);
assert!(!colors.is_empty(), "the placeholder paints");
assert!(colors.iter().all(|color| *color == muted));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn alt_gr_types_its_character_and_control_does_not() {
let (mut harness, value) = mounted("");
focus(&mut harness);
let alt_gr = Modifiers {
control: true,
alt: true,
..Modifiers::default()
};
harness.press_key_with(Key::Character(String::from("@")), alt_gr);
let control = Modifiers {
control: true,
..Modifiers::default()
};
harness.press_key_with(Key::Character(String::from("x")), control);
harness.press_key_with(Key::Character(String::from("a")), alt_gr);
assert_eq!(
value.get(),
"@a",
"AltGr types even the letter Control selects everything with, and a \
Control shortcut types nothing"
);
}
#[test]
fn an_input_is_one_line_tall_without_a_height() {
let value = RwSignal::new(String::new());
let bound = value.clone();
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
panel()
.column()
.child(text_input(bound.clone()).width(px(VIEWPORT.0)))
});
harness.frame();
harness.settle();
let line = TextStyle::default().size * TextStyle::default().line_height;
let mut height = None;
harness.app().visit_nodes(|_depth, type_name, rect| {
if type_name.rsplit("::").next() == Some("TextInput") {
height = Some(rect.height());
}
});
let height = height.expect("the input lays out");
assert!(
(height - line).abs() < 0.5,
"the input measures one line tall: {height} against {line}"
);
}
#[test]
fn a_word_drag_to_the_left_leaves_the_caret_at_the_dragged_end() {
let value = RwSignal::new(String::new());
let editor = Editor::new(value);
editor.set_style(&TextStyle::default());
editor.set_text("alpha beta gamma");
let width = editor.layout_size().width;
editor.select_word_at_point(width - 4.0, 5.0);
editor.extend_to_point(4.0, 5.0);
assert_eq!(editor.selection(), 0..16, "the drag takes in every word");
let caret = editor.cursor_geometry(1.0).expect("a caret");
assert!(
caret.x0 < 1.0,
"the caret follows the pointer to the left end: {caret:?}"
);
editor.extend_right();
assert_eq!(
editor.selection(),
1..16,
"Shift and Right move the dragged end, not the double click's"
);
}
#[test]
fn an_input_composing_a_line_break_keeps_its_first_line_in_view() {
let value = RwSignal::new(String::new());
let bound = value.clone();
let mut harness = Harness::new(VIEWPORT.0, VIEWPORT.1, 1.0, move || {
panel()
.column()
.child(text_input(bound.clone()).width(px(VIEWPORT.0)))
});
harness.frame();
harness.click(Point { x: 60.0, y: 5.0 });
harness.settle();
harness.event(InputEvent::ImePreedit {
text: String::from("one\ntwo"),
cursor: Some((7, 7)),
});
harness.settle();
let output = harness.app().frame();
let mut top = f32::INFINITY;
for item in output.list.items() {
if let DisplayItem::Op(PaintOp::Glyphs(run)) = item {
for glyph in &run.glyphs {
top = top.min(glyph.y);
}
}
}
assert!(
top > 0.0 && top < VIEWPORT.1,
"the one-line input never scrolls up or down, so the first line's \
baseline stays inside it: {top}"
);
}
#[test]
fn an_input_method_commit_with_line_breaks_types_one_line() {
let (mut harness, value) = mounted("");
focus(&mut harness);
harness.event(InputEvent::ImeCommit {
text: String::from("one\ntwo\r\nthree"),
});
assert_eq!(value.get(), "one two three", "each break becomes a space");
}
#[test]
fn an_outside_write_with_line_breaks_shows_one_line() {
let (mut harness, value) = mounted("");
harness.settle();
value.set(String::from("one\ntwo\u{2028}three"));
harness.settle();
assert_eq!(
value.get(),
"one two three",
"the input writes the one line it kept back to the signal"
);
}
#[derive(Clone, Debug)]
enum Op {
Type(String),
Key(Key, bool),
WordKey(Key, bool),
AltBackspace,
SelectAll,
Write(String),
Undo,
Redo,
}
fn any_text() -> impl Strategy<Value = String> {
proptest::collection::vec(
proptest::char::any().prop_filter("printable only", |c| !c.is_control()),
0..4,
)
.prop_map(String::from_iter)
}
fn any_key() -> impl Strategy<Value = Key> {
prop_oneof![
Just(Key::Backspace),
Just(Key::Delete),
Just(Key::ArrowLeft),
Just(Key::ArrowRight),
Just(Key::Home),
Just(Key::End),
]
}
fn any_op() -> impl Strategy<Value = Op> {
prop_oneof![
3 => any_text().prop_map(Op::Type),
4 => (any_key(), any::<bool>()).prop_map(|(key, shift)| Op::Key(key, shift)),
2 => (any_key(), any::<bool>()).prop_map(|(key, shift)| Op::WordKey(key, shift)),
1 => Just(Op::AltBackspace),
1 => Just(Op::SelectAll),
1 => any_text().prop_map(Op::Write),
1 => Just(Op::Undo),
1 => Just(Op::Redo),
]
}
fn apply_to_reference(editor: &Editor, op: &Op) {
match op {
Op::Type(text) => text.chars().for_each(|c| editor.insert(&c.to_string())),
Op::Key(key, shift) => match (key, shift) {
(Key::Backspace, _) => editor.backspace(),
(Key::Delete, _) => editor.delete(),
(Key::ArrowLeft, false) => editor.move_left(),
(Key::ArrowLeft, true) => editor.extend_left(),
(Key::ArrowRight, false) => editor.move_right(),
(Key::ArrowRight, true) => editor.extend_right(),
(Key::Home, false) => editor.move_to_start(),
(Key::Home, true) => editor.extend_to_start(),
(Key::End, false) => editor.move_to_end(),
(Key::End, true) => editor.extend_to_end(),
_ => unreachable!("the strategy makes no other keys"),
},
Op::WordKey(key, shift) => match (key, shift) {
(Key::Backspace, _) => editor.backspace_word(),
(Key::Delete, _) => editor.delete_word(),
(Key::ArrowLeft, false) => editor.move_word_left(),
(Key::ArrowLeft, true) => editor.extend_word_left(),
(Key::ArrowRight, false) => editor.move_word_right(),
(Key::ArrowRight, true) => editor.extend_word_right(),
(Key::Home, false) => editor.move_to_start(),
(Key::Home, true) => editor.extend_to_start(),
(Key::End, false) => editor.move_to_end(),
(Key::End, true) => editor.extend_to_end(),
_ => unreachable!("the strategy makes no other keys"),
},
Op::AltBackspace => editor.backspace_word(),
Op::SelectAll => editor.select_all(),
Op::Write(text) if !editor.has_text(text) => editor.set_text(text),
Op::Write(_) => {}
Op::Undo => editor.undo(),
Op::Redo => editor.redo(),
}
}
fn apply_to_input(harness: &mut Harness, value: &RwSignal<String>, op: &Op) {
let control = Modifiers {
control: true,
..Modifiers::default()
};
match op {
Op::Type(text) => harness.type_text(text),
Op::Key(key, shift) => harness.press_key_with(
key.clone(),
Modifiers {
shift: *shift,
..Modifiers::default()
},
),
Op::WordKey(key, shift) => harness.press_key_with(
key.clone(),
Modifiers {
shift: *shift,
..control
},
),
Op::AltBackspace => harness.press_key_with(
Key::Backspace,
Modifiers {
alt: true,
..Modifiers::default()
},
),
Op::SelectAll => harness.press_key_with(Key::Character(String::from("a")), command()),
Op::Write(text) => value.set(text.clone()),
Op::Undo => harness.press_key_with(Key::Character(String::from("z")), command()),
Op::Redo => harness.press_key_with(
Key::Character(String::from("z")),
Modifiers {
shift: true,
..command()
},
),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn random_edits_keep_the_signal_in_step(ops in proptest::collection::vec(any_op(), 0..30)) {
let (mut harness, value) = mounted("");
focus(&mut harness);
let reference = Editor::single_line(RwSignal::new(String::new()));
reference.set_style(&TextStyle::default());
for op in &ops {
apply_to_input(&mut harness, &value, op);
harness.settle();
apply_to_reference(&reference, op);
prop_assert_eq!(
value.get(),
reference.text(),
"the signal holds the text the edits made, after {:?}",
op
);
let raw = reference.raw_text();
let selection = reference.selection();
prop_assert!(raw.is_char_boundary(selection.start));
prop_assert!(raw.is_char_boundary(selection.end));
}
}
}
const STEPS_PER_OP: usize = 4;
const MAX_UNDO_OPS: usize = 100 / STEPS_PER_OP;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn undoing_every_step_returns_to_the_last_outside_write_and_redo_returns(
ops in proptest::collection::vec(any_op(), 0..=MAX_UNDO_OPS)
) {
let reference = Editor::new(RwSignal::new(String::new()));
reference.set_style(&TextStyle::default());
let ops: Vec<Op> = ops
.into_iter()
.filter(|op| !matches!(op, Op::Undo | Op::Redo))
.collect();
let mut baseline = String::new();
for op in &ops {
if let Op::Write(text) = op
&& !reference.has_text(text)
{
baseline = text.clone();
}
apply_to_reference(&reference, op);
}
let end = reference.text();
for _ in 0..=STEPS_PER_OP * ops.len() {
reference.undo();
}
prop_assert_eq!(reference.text(), baseline, "undo walks back to the last outside write");
for _ in 0..=STEPS_PER_OP * ops.len() {
reference.redo();
}
prop_assert_eq!(reference.text(), end, "redo walks forward to where the edits ended");
}
}