use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use super::*;
use crate::event::{Event, EventResult, Key, Modifiers};
use crate::widget::Widget;
const FONT_BYTES: &[u8] = include_bytes!("../../../../demo/assets/CascadiaCode.ttf");
fn font() -> Arc<Font> {
Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
}
fn laid_out(mut ta: TextArea, w: f64, h: f64) -> TextArea {
ta.layout(Size::new(w, h));
ta
}
#[test]
fn hint_text_defaults_and_overrides() {
let ta = TextArea::new(font());
assert_eq!(ta.hint, "Type here…");
let ta = TextArea::new(font()).with_hint_text("Type something!");
assert_eq!(ta.hint, "Type something!");
}
#[test]
fn resolved_alignment_prefers_cell_over_static() {
let hcell = Rc::new(Cell::new(TextHAlign::Right));
let vcell = Rc::new(Cell::new(TextVAlign::Bottom));
let ta = TextArea::new(font())
.with_content_h_align(TextHAlign::Left)
.with_content_v_align(TextVAlign::Top)
.with_h_align_cell(Rc::clone(&hcell))
.with_v_align_cell(Rc::clone(&vcell));
assert_eq!(ta.resolved_h_align(), TextHAlign::Right);
assert_eq!(ta.resolved_v_align(), TextVAlign::Bottom);
hcell.set(TextHAlign::Center);
assert_eq!(ta.resolved_h_align(), TextHAlign::Center);
}
#[test]
fn horizontal_align_shifts_line_start() {
let text = "hi"; let left = laid_out(
TextArea::new(font())
.with_text(text)
.with_content_h_align(TextHAlign::Left),
400.0,
120.0,
);
let center = laid_out(
TextArea::new(font())
.with_text(text)
.with_content_h_align(TextHAlign::Center),
400.0,
120.0,
);
let right = laid_out(
TextArea::new(font())
.with_text(text)
.with_content_h_align(TextHAlign::Right),
400.0,
120.0,
);
let l = &left.cached_lines[0];
let c = ¢er.cached_lines[0];
let r = &right.cached_lines[0];
let lx = left.line_x_start(l);
let cx = center.line_x_start(c);
let rx = right.line_x_start(r);
assert!(cx > lx, "center should start right of left ({cx} vs {lx})");
assert!(rx > cx, "right should start right of center ({rx} vs {cx})");
}
#[test]
fn vertical_align_shifts_content_block() {
let top = laid_out(
TextArea::new(font())
.with_text("x")
.with_content_v_align(TextVAlign::Top),
300.0,
300.0,
);
let center = laid_out(
TextArea::new(font())
.with_text("x")
.with_content_v_align(TextVAlign::Center),
300.0,
300.0,
);
let bottom = laid_out(
TextArea::new(font())
.with_text("x")
.with_content_v_align(TextVAlign::Bottom),
300.0,
300.0,
);
assert_eq!(top.v_align_shift(), 0.0);
assert!(center.v_align_shift() > 0.0);
assert!(bottom.v_align_shift() > center.v_align_shift());
assert!(top.line_top_y(0) > center.line_top_y(0));
assert!(center.line_top_y(0) > bottom.line_top_y(0));
}
#[test]
fn selection_and_selected_text() {
let ta = TextArea::new(font()).with_text("hello world");
assert_eq!(ta.selection(), None);
assert_eq!(ta.selected_text(), "");
{
let st = ta.edit_state();
let mut st = st.borrow_mut();
st.anchor = 6;
st.cursor = 11;
}
assert_eq!(ta.selection(), Some((6, 11)));
assert_eq!(ta.selected_text(), "world");
{
let st = ta.edit_state();
let mut st = st.borrow_mut();
st.anchor = 11;
st.cursor = 6;
}
assert_eq!(ta.selection(), Some((6, 11)));
assert_eq!(ta.selected_text(), "world");
}
#[test]
fn cursor_to_start_and_end() {
let mut ta = TextArea::new(font()).with_text("abcdef");
ta.set_cursor_to_start();
{
let st = ta.edit_state();
let st = st.borrow();
assert_eq!((st.cursor, st.anchor), (0, 0));
}
ta.set_cursor_to_end();
{
let st = ta.edit_state();
let st = st.borrow();
assert_eq!((st.cursor, st.anchor), (6, 6));
}
}
#[test]
fn shared_edit_state_survives_across_widgets() {
let shared = Rc::new(RefCell::new(TextEditState {
text: "seed".into(),
cursor: 4,
anchor: 4,
epoch: 0,
}));
let a = TextArea::new(font()).with_edit_state(Rc::clone(&shared));
let b = TextArea::new(font()).with_edit_state(Rc::clone(&shared));
assert_eq!(a.text(), "seed");
assert_eq!(b.text(), "seed");
{
let mut st = shared.borrow_mut();
st.text = "changed".into();
st.note_text_change();
}
assert_eq!(a.text(), "changed");
assert_eq!(b.text(), "changed");
}
#[test]
fn external_text_change_reflows_via_epoch() {
let shared = Rc::new(RefCell::new(TextEditState {
text: "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd".into(),
cursor: 0,
anchor: 0,
epoch: 0,
}));
let mut ta = laid_out(
TextArea::new(font()).with_edit_state(Rc::clone(&shared)),
80.0,
200.0,
);
let wrapped = ta.visual_line_count();
assert!(wrapped > 1, "expected multi-line wrap, got {wrapped}");
{
let mut st = shared.borrow_mut();
st.text = "hi".into();
st.cursor = 2;
st.anchor = 2;
st.note_text_change();
}
ta.layout(Size::new(80.0, 200.0));
assert_eq!(ta.visual_line_count(), 1);
}
#[test]
fn focus_id_is_exposed() {
let ta = TextArea::new(font());
assert_eq!(ta.focus_id(), None);
let ta = TextArea::new(font()).with_focus_id(4242);
assert_eq!(ta.focus_id(), Some(4242));
}
#[test]
fn key_intercept_runs_before_default_and_can_consume() {
let fired = Rc::new(Cell::new(0u32));
let fired2 = Rc::clone(&fired);
let mut ta = laid_out(
TextArea::new(font())
.with_text("abc")
.with_key_intercept(move |key, mods| {
if (mods.ctrl || mods.meta) && matches!(key, Key::Char('y') | Key::Char('Y')) {
fired2.set(fired2.get() + 1);
true
} else {
false
}
}),
200.0,
80.0,
);
ta.on_event(&Event::FocusGained);
let res = ta.on_event(&Event::KeyDown {
key: Key::Char('y'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
});
assert_eq!(res, EventResult::Consumed);
assert_eq!(fired.get(), 1);
assert_eq!(ta.text(), "abc");
let res = ta.on_event(&Event::KeyDown {
key: Key::Char('z'),
modifiers: Modifiers::default(),
});
assert_eq!(res, EventResult::Consumed);
assert_eq!(fired.get(), 1);
assert_eq!(ta.text(), "abcz");
}
fn key(ta: &mut TextArea, k: Key) {
ta.on_event(&Event::KeyDown {
key: k,
modifiers: Modifiers::default(),
});
}
#[test]
fn on_change_fires_for_typing_delete_and_records_latest_text() {
let seen = Rc::new(RefCell::new(Vec::<String>::new()));
let seen2 = Rc::clone(&seen);
let mut ta = laid_out(
TextArea::new(font()).on_change(move |s| seen2.borrow_mut().push(s.to_string())),
200.0,
80.0,
);
ta.on_event(&Event::FocusGained);
key(&mut ta, Key::Char('h'));
key(&mut ta, Key::Char('i'));
key(&mut ta, Key::Backspace);
let seen = seen.borrow();
assert_eq!(
*seen,
vec!["h".to_string(), "hi".to_string(), "h".to_string()]
);
}
#[test]
fn on_change_fires_for_key_intercept_edit_when_epoch_advances() {
let changed = Rc::new(Cell::new(0u32));
let changed2 = Rc::clone(&changed);
let state = Rc::new(RefCell::new(TextEditState {
text: "abc".into(),
cursor: 0,
anchor: 3, epoch: 0,
}));
let state_key = Rc::clone(&state);
let mut ta = laid_out(
TextArea::new(font())
.with_edit_state(Rc::clone(&state))
.with_key_intercept(move |key, mods| {
if (mods.ctrl || mods.meta) && matches!(key, Key::Char('y') | Key::Char('Y')) {
let mut st = state_key.borrow_mut();
if let Some((lo, hi)) = st.selection_range() {
let up = st.text[lo..hi].to_uppercase();
st.text.replace_range(lo..hi, &up);
st.note_text_change();
}
true
} else {
false
}
})
.on_change(move |_| changed2.set(changed2.get() + 1)),
200.0,
80.0,
);
ta.on_event(&Event::FocusGained);
ta.on_event(&Event::KeyDown {
key: Key::Char('y'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
});
assert_eq!(
changed.get(),
1,
"epoch-advancing intercept must fire on_change"
);
assert_eq!(state.borrow().text, "ABC");
}
#[test]
fn on_change_silent_for_intercept_without_text_edit() {
let changed = Rc::new(Cell::new(0u32));
let changed2 = Rc::clone(&changed);
let mut ta = laid_out(
TextArea::new(font())
.with_text("abc")
.with_key_intercept(|_key, _mods| true) .on_change(move |_| changed2.set(changed2.get() + 1)),
200.0,
80.0,
);
ta.on_event(&Event::FocusGained);
key(&mut ta, Key::Char('z'));
assert_eq!(changed.get(), 0, "no text change → no on_change");
}
fn multiline(n: usize, w: f64, h: f64) -> TextArea {
let text = (0..n)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
laid_out(
TextArea::new(font()).with_font_size(13.0).with_text(text),
w,
h,
)
}
#[test]
fn scroll_to_cursor_reveals_last_line_and_first_line() {
let mut ta = multiline(12, 200.0, 80.0);
let max = ta.max_scroll_y();
assert!(
max > 0.0,
"12 lines in an 80px box must overflow; max={max}"
);
ta.ensure_cursor_visible();
assert!(
(ta.scroll_offset() - max).abs() < 0.5,
"caret at end must scroll to bottom: off={} max={max}",
ta.scroll_offset()
);
ta.set_cursor_to_start();
ta.ensure_cursor_visible();
assert_eq!(
ta.scroll_offset(),
0.0,
"caret at start must scroll to the top"
);
}
#[test]
fn caret_geometry_moves_on_screen_after_scroll_to_cursor() {
let mut ta = multiline(12, 200.0, 80.0);
let cursor = ta.cursor();
let y_before = ta.pos_for_cursor(cursor).y;
assert!(
y_before < 0.0,
"caret should start off the bottom of the viewport, got y={y_before}"
);
ta.ensure_cursor_visible();
let y_after = ta.pos_for_cursor(cursor).y;
assert!(
(0.0..=80.0).contains(&y_after),
"caret must be on-screen after scroll: y={y_after}"
);
}
#[test]
fn content_that_fits_never_scrolls() {
let mut ta = multiline(3, 300.0, 300.0);
assert_eq!(ta.max_scroll_y(), 0.0, "3 lines fit a 300px box");
ta.ensure_cursor_visible();
assert_eq!(ta.scroll_offset(), 0.0);
assert!(
!ta.scroll_by_wheel(-40.0),
"wheel is a no-op when nothing overflows"
);
assert_eq!(ta.scroll_offset(), 0.0);
}
#[test]
fn wheel_scrolls_within_bounds_and_clamps() {
let mut ta = multiline(12, 200.0, 80.0);
let max = ta.max_scroll_y();
assert!(ta.scroll_by_wheel(-40.0), "wheel must move the offset");
assert!(ta.scroll_offset() > 0.0);
for _ in 0..50 {
ta.scroll_by_wheel(-40.0);
}
assert!((ta.scroll_offset() - max).abs() < 0.5);
assert!(!ta.scroll_by_wheel(-40.0), "clamped at the bottom");
for _ in 0..50 {
ta.scroll_by_wheel(40.0);
}
assert_eq!(ta.scroll_offset(), 0.0);
}
#[test]
fn caret_visible_segment_clamps_and_hides_off_screen() {
use super::widget_impl::caret_visible_segment;
assert_eq!(
caret_visible_segment(20.0, 18.0, 8.0, 72.0),
Some((20.0, 38.0))
);
assert_eq!(
caret_visible_segment(0.0, 18.0, 8.0, 72.0),
Some((8.0, 18.0))
);
assert_eq!(
caret_visible_segment(64.0, 18.0, 8.0, 72.0),
Some((64.0, 72.0))
);
assert_eq!(caret_visible_segment(-40.0, 18.0, 8.0, 72.0), None);
assert_eq!(caret_visible_segment(200.0, 18.0, 8.0, 72.0), None);
}
#[test]
fn key_intercept_edit_scrolls_caret_into_view() {
let state = Rc::new(RefCell::new(TextEditState {
text: (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n"),
cursor: 0,
anchor: 0,
epoch: 0,
}));
let state_key = Rc::clone(&state);
let mut ta = laid_out(
TextArea::new(font())
.with_font_size(13.0)
.with_edit_state(Rc::clone(&state))
.with_key_intercept(move |key, _mods| {
if matches!(key, Key::Char('g')) {
let mut st = state_key.borrow_mut();
st.text.push('!');
let end = st.text.len();
st.cursor = end;
st.anchor = end;
st.note_text_change();
true
} else {
false
}
}),
200.0,
80.0,
);
ta.on_event(&Event::FocusGained);
assert_eq!(ta.scroll_offset(), 0.0, "starts at the top");
ta.on_event(&Event::KeyDown {
key: Key::Char('g'),
modifiers: Modifiers::default(),
});
assert!(
ta.scroll_offset() > 0.0,
"intercept edit at document end must scroll the caret into view, got {}",
ta.scroll_offset()
);
let cursor = ta.cursor();
let y = ta.pos_for_cursor(cursor).y;
assert!(
(0.0..=80.0).contains(&y),
"caret must be on-screen after intercept edit: y={y}"
);
}
#[test]
fn first_line_top_not_clipped_at_scroll_top() {
let mut ta = multiline(12, 200.0, 80.0);
ta.set_cursor_to_start();
ta.ensure_cursor_visible();
assert_eq!(ta.scroll_offset(), 0.0, "scrolled to the very top");
let inner_top = ta.bounds.height - ta.padding; let ascent = ta.font.ascender_px(ta.font_size);
let glyph_top = ta.line_baseline_y(0) + ascent;
assert!(
glyph_top <= inner_top + 0.01,
"first line ascenders clipped: glyph_top={glyph_top} > inner_top={inner_top}"
);
}
#[test]
fn external_cursor_move_scrolls_into_view_on_layout() {
let state = Rc::new(RefCell::new(TextEditState {
text: (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n"),
cursor: 0,
anchor: 0,
epoch: 0,
}));
let mut ta = laid_out(
TextArea::new(font())
.with_font_size(13.0)
.with_edit_state(Rc::clone(&state)),
200.0,
80.0,
);
assert_eq!(
ta.scroll_offset(),
0.0,
"first layout leaves the view at the top"
);
{
let mut st = state.borrow_mut();
let end = st.text.len();
st.cursor = end;
st.anchor = end;
}
ta.layout(Size::new(200.0, 80.0));
assert!(
ta.scroll_offset() > 0.0,
"external cursor move must scroll on layout: off={}",
ta.scroll_offset()
);
let y = ta.pos_for_cursor(ta.cursor()).y;
assert!(
(0.0..=80.0).contains(&y),
"caret must be on-screen after the external move: y={y}"
);
}
fn key_mods(ta: &mut TextArea, k: Key, mods: Modifiers) {
ta.on_event(&Event::KeyDown {
key: k,
modifiers: mods,
});
}
fn shift() -> Modifiers {
Modifiers {
shift: true,
..Default::default()
}
}
fn ctrl() -> Modifiers {
Modifiers {
ctrl: true,
..Default::default()
}
}
fn place_cursor(ta: &TextArea, pos: usize) {
let mut st = ta.edit.borrow_mut();
st.cursor = pos;
st.anchor = pos;
}
#[test]
fn home_moves_to_visual_line_start_end_to_line_end() {
let mut ta = multiline(5, 200.0, 300.0); ta.on_event(&Event::FocusGained);
let line = 2usize;
let (start, end) = (ta.cached_lines[line].start, ta.cached_lines[line].end);
place_cursor(&ta, start + 2);
key(&mut ta, Key::Home);
assert_eq!(ta.cursor(), start, "Home → start of the visual line");
key(&mut ta, Key::End);
assert_eq!(ta.cursor(), end, "End → end of the visual line");
}
#[test]
fn ctrl_home_and_ctrl_end_jump_to_document_bounds() {
let mut ta = multiline(12, 200.0, 80.0);
ta.on_event(&Event::FocusGained);
place_cursor(&ta, ta.cached_lines[6].start);
key_mods(&mut ta, Key::Home, ctrl());
assert_eq!(ta.cursor(), 0, "Ctrl+Home → document start");
assert_eq!(ta.scroll_offset(), 0.0, "Ctrl+Home scrolls to the top");
key_mods(&mut ta, Key::End, ctrl());
assert_eq!(ta.cursor(), ta.text().len(), "Ctrl+End → document end");
assert!(
ta.scroll_offset() > 0.0,
"Ctrl+End scrolls the caret into view: off={}",
ta.scroll_offset()
);
}
#[test]
fn shift_home_extends_selection_to_line_start() {
let mut ta = multiline(5, 200.0, 300.0);
ta.on_event(&Event::FocusGained);
let line = 2usize;
let start = ta.cached_lines[line].start;
place_cursor(&ta, start + 3);
key_mods(&mut ta, Key::Home, shift());
assert_eq!(ta.cursor(), start);
assert_eq!(
ta.selection(),
Some((start, start + 3)),
"Shift+Home selects back to the line start"
);
}
#[test]
fn page_down_and_page_up_move_by_viewport_and_scroll() {
let mut ta = multiline(30, 200.0, 80.0);
ta.on_event(&Event::FocusGained);
let page = ta.page_lines();
assert!(page >= 1, "viewport holds at least one line");
ta.set_cursor_to_start();
ta.ensure_cursor_visible();
assert_eq!(ta.scroll_offset(), 0.0);
key(&mut ta, Key::PageDown);
assert_eq!(
ta.line_for_cursor(ta.cursor()),
page,
"PageDown advances one viewport of lines"
);
let y = ta.pos_for_cursor(ta.cursor()).y;
assert!(
(0.0..=80.0).contains(&y),
"caret visible after PageDown: y={y}"
);
ta.set_cursor_to_end();
ta.ensure_cursor_visible();
let last = ta.visual_line_count() - 1;
key(&mut ta, Key::PageUp);
assert_eq!(
ta.line_for_cursor(ta.cursor()),
last - page,
"PageUp climbs one viewport of lines"
);
}