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"
);
}
use super::widget_impl::segment_highlight;
use crate::color::Color;
const BASE: Color = Color::rgb(1.0, 1.0, 1.0);
const RUN: Color = Color::rgb(1.0, 0.0, 0.0);
fn assert_covers_once(text: &str, segs: &[(usize, usize, Color)]) {
let mut pos = 0usize;
for &(s, e, _) in segs {
assert_eq!(s, pos, "segment start must abut the previous end: {segs:?}");
assert!(e > s, "segment must be non-empty: {segs:?}");
pos = e;
}
assert_eq!(pos, text.len(), "segments must cover the whole line: {segs:?}");
}
#[test]
fn segment_highlight_fills_gaps_and_runs_once() {
let text = "let x = 1;";
let spans = [(0usize, 3usize, RUN), (8usize, 9usize, RUN)];
let segs = segment_highlight(text, &spans, BASE);
assert_covers_once(text, &segs);
assert_eq!(
segs,
vec![
(0, 3, RUN), (3, 8, BASE), (8, 9, RUN), (9, 10, BASE), ]
);
}
#[test]
fn segment_highlight_no_spans_is_single_base_run() {
let text = "plain";
let segs = segment_highlight(text, &[], BASE);
assert_eq!(segs, vec![(0, 5, BASE)]);
}
#[test]
fn segment_highlight_drops_invalid_and_resolves_overlap() {
let text = "abcdef";
let spans = [
(2usize, 2usize, RUN), (4usize, 3usize, RUN), (0usize, 10usize, RUN), (0usize, 3usize, RUN), (2usize, 5usize, BASE), ];
let segs = segment_highlight(text, &spans, BASE);
assert_covers_once(text, &segs);
assert_eq!(
segs,
vec![(0, 3, RUN), (3, 5, BASE), (5, 6, BASE)]
);
}
#[test]
fn segment_highlight_empty_text_is_empty() {
assert!(segment_highlight("", &[(0, 0, RUN)], BASE).is_empty());
}