use std::panic::Location;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind, UiKey};
use crate::metrics::MetricsRole;
use crate::selection::{Selection, SelectionPoint, SelectionRange};
use crate::style::StyleProfile;
use crate::text::metrics::{self, TextGeometry};
use crate::tokens;
use crate::tree::*;
use crate::widgets::text::text;
use crate::widgets::text_input::{TextSelection, replace_selection};
pub(crate) const TEXT_AREA_SELECTION_LAYER: &str = "text_area_selection_layer";
pub(crate) const TEXT_AREA_CARET_LAYER: &str = "text_area_caret_layer";
#[track_caller]
pub fn text_area(value: &str, selection: &Selection, key: &str) -> El {
build_text_area(key, value, selection.within(key)).key(key)
}
#[track_caller]
fn build_text_area(key: &str, value: &str, view: Option<TextSelection>) -> El {
let mut content_children: Vec<El> = Vec::with_capacity(3);
if view.is_some_and(|selection| !selection.is_collapsed()) {
content_children.push(text_area_paint_layer(TEXT_AREA_SELECTION_LAYER, key, value));
}
content_children.push(
text(value)
.wrap_text()
.width(Size::Fill(1.0))
.height(Size::Hug),
);
if view.is_some() {
content_children.push(
text_area_paint_layer(TEXT_AREA_CARET_LAYER, key, value)
.alpha_follows_focused_ancestor()
.blink_when_focused(),
);
}
let inner = El::new(Kind::Custom("text_area_content"))
.axis(Axis::Overlay)
.align(Align::Start)
.justify(Justify::Start)
.width(Size::Fill(1.0))
.height(Size::Hug)
.children(content_children);
let viewport = crate::tree::scroll([inner]);
El::new(Kind::Custom("text_area"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::TextArea)
.surface_role(SurfaceRole::Input)
.focusable()
.always_show_focus_ring()
.capture_keys()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.cursor(Cursor::Text)
.fill(tokens::MUTED)
.stroke(tokens::BORDER)
.default_radius(tokens::RADIUS_MD)
.axis(Axis::Column)
.align(Align::Stretch)
.justify(Justify::Start)
.width(Size::Fill(1.0))
.height(Size::Hug)
.default_padding(Sides::xy(tokens::SPACE_3, tokens::SPACE_2))
.child(viewport)
}
fn text_area_paint_layer(kind: &'static str, key: &str, value: &str) -> El {
let mut layer = El::new(Kind::Custom(kind))
.style_profile(StyleProfile::Solid)
.width(Size::Fill(1.0))
.height(Size::Fill(1.0));
layer.text_link = Some(key.to_string());
layer.tooltip = Some(value.to_string());
layer
}
fn line_height_px() -> f32 {
tokens::TEXT_SM.line_height
}
fn text_area_geometry(value: &str, available_width: Option<f32>) -> TextGeometry<'_> {
TextGeometry::new(
value,
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
if available_width.is_some() {
TextWrap::Wrap
} else {
TextWrap::NoWrap
},
available_width,
)
}
pub fn drag_autoscroll_request_for(
event: &UiEvent,
key: &str,
) -> Option<crate::scroll::ScrollRequest> {
if !matches!(event.kind, UiEventKind::Drag) {
return None;
}
let (_, py) = event.pointer?;
let target = event.target.as_ref()?;
if target.key != key {
return None;
}
let viewport_top = target.rect.y + tokens::SPACE_2;
let viewport_bottom = target.rect.bottom() - tokens::SPACE_2;
let line_h = line_height_px();
if py < viewport_top {
let content_y_above = (target.scroll_offset_y - line_h).max(0.0);
Some(crate::scroll::ScrollRequest::ensure_visible(
key,
content_y_above,
line_h,
))
} else if py > viewport_bottom {
let viewport_h = (viewport_bottom - viewport_top).max(line_h);
let content_y_below = target.scroll_offset_y + viewport_h;
Some(crate::scroll::ScrollRequest::ensure_visible(
key,
content_y_below,
line_h,
))
} else {
None
}
}
pub fn caret_scroll_request_for(
value: &str,
selection: &Selection,
key: &str,
) -> Option<crate::scroll::ScrollRequest> {
let view = selection.within(key)?;
let head = clamp_to_char_boundary(value, view.head.min(value.len()));
let geometry = text_area_geometry(value, None);
let (_, caret_y) = geometry.caret_xy(head);
Some(crate::scroll::ScrollRequest::ensure_visible(
key,
caret_y,
line_height_px(),
))
}
pub fn apply_event(
value: &mut String,
selection: &mut Selection,
key: &str,
event: &UiEvent,
) -> bool {
let mut local = selection.within(key).unwrap_or_default();
let changed = fold_event_local(value, &mut local, event);
if changed {
selection.range = Some(SelectionRange {
anchor: SelectionPoint::new(key, local.anchor),
head: SelectionPoint::new(key, local.head),
});
}
changed
}
fn fold_event_local(value: &mut String, selection: &mut TextSelection, event: &UiEvent) -> bool {
selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
let wrap_width = wrap_width_for_event(event);
match event.kind {
UiEventKind::TextInput => {
let Some(insert) = event.text.as_deref() else {
return false;
};
if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
return false;
}
let filtered: String = insert
.chars()
.filter(|c| *c == '\n' || !c.is_control())
.collect();
if filtered.is_empty() {
return false;
}
replace_selection(value, selection, &filtered);
true
}
UiEventKind::MiddleClick => {
let Some(byte) = caret_byte_at_with_width(value, event, wrap_width) else {
return false;
};
*selection = TextSelection::caret(byte);
if let Some(insert) = event.text.as_deref() {
replace_selection(value, selection, insert);
}
true
}
UiEventKind::KeyDown => {
let Some(kp) = event.key_press.as_ref() else {
return false;
};
let mods = kp.modifiers;
if mods.ctrl
&& !mods.alt
&& !mods.logo
&& let UiKey::Character(c) = &kp.key
&& c.eq_ignore_ascii_case("a")
{
let len = value.len();
if selection.anchor == 0 && selection.head == len {
return false;
}
*selection = TextSelection {
anchor: 0,
head: len,
};
return true;
}
match kp.key {
UiKey::Escape => {
if selection.is_collapsed() {
return false;
}
selection.anchor = selection.head;
true
}
UiKey::Enter => {
replace_selection(value, selection, "\n");
true
}
UiKey::Backspace => {
if !selection.is_collapsed() {
replace_selection(value, selection, "");
return true;
}
if selection.head == 0 {
return false;
}
let prev = prev_char_boundary(value, selection.head);
value.replace_range(prev..selection.head, "");
selection.head = prev;
selection.anchor = prev;
true
}
UiKey::Delete => {
if !selection.is_collapsed() {
replace_selection(value, selection, "");
return true;
}
if selection.head >= value.len() {
return false;
}
let next = next_char_boundary(value, selection.head);
value.replace_range(selection.head..next, "");
true
}
UiKey::ArrowLeft => {
let target = if selection.is_collapsed() || mods.shift {
if selection.head == 0 {
return false;
}
prev_char_boundary(value, selection.head)
} else {
selection.ordered().0
};
selection.head = target;
if !mods.shift {
selection.anchor = target;
}
true
}
UiKey::ArrowRight => {
let target = if selection.is_collapsed() || mods.shift {
if selection.head >= value.len() {
return false;
}
next_char_boundary(value, selection.head)
} else {
selection.ordered().1
};
selection.head = target;
if !mods.shift {
selection.anchor = target;
}
true
}
UiKey::ArrowUp => {
let new = move_caret_vertically(value, selection.head, -1, wrap_width);
if new == selection.head {
return false;
}
selection.head = new;
if !mods.shift {
selection.anchor = new;
}
true
}
UiKey::ArrowDown => {
let new = move_caret_vertically(value, selection.head, 1, wrap_width);
if new == selection.head {
return false;
}
selection.head = new;
if !mods.shift {
selection.anchor = new;
}
true
}
UiKey::PageUp => {
let new = move_caret_vertically(
value,
selection.head,
page_line_delta_for_event(event, -1),
wrap_width,
);
if new == selection.head {
return false;
}
selection.head = new;
if !mods.shift {
selection.anchor = new;
}
true
}
UiKey::PageDown => {
let new = move_caret_vertically(
value,
selection.head,
page_line_delta_for_event(event, 1),
wrap_width,
);
if new == selection.head {
return false;
}
selection.head = new;
if !mods.shift {
selection.anchor = new;
}
true
}
UiKey::Home => {
let (line_start, _) = visual_line_range(value, selection.head, wrap_width);
if selection.head == line_start
&& (mods.shift || selection.anchor == line_start)
{
return false;
}
selection.head = line_start;
if !mods.shift {
selection.anchor = line_start;
}
true
}
UiKey::End => {
let (_, line_end) = visual_line_range(value, selection.head, wrap_width);
if selection.head == line_end && (mods.shift || selection.anchor == line_end) {
return false;
}
selection.head = line_end;
if !mods.shift {
selection.anchor = line_end;
}
true
}
_ => false,
}
}
UiEventKind::PointerDown => {
let (Some((px, py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
return false;
};
let local_x = px - target.rect.x - tokens::SPACE_3;
let local_y = py - target.rect.y - tokens::SPACE_2 + target.scroll_offset_y;
let pos = caret_from_xy(value, local_x, local_y, wrap_width);
if !event.modifiers.shift {
match event.click_count {
2 => {
let (lo, hi) = crate::selection::word_range_at(value, pos);
selection.anchor = lo;
selection.head = hi;
return true;
}
n if n >= 3 => {
let (lo, hi) = crate::selection::line_range_at(value, pos);
selection.anchor = lo;
selection.head = hi;
return true;
}
_ => {}
}
}
selection.head = pos;
if !event.modifiers.shift {
selection.anchor = pos;
}
true
}
UiEventKind::Drag => {
let (Some((px, py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
return false;
};
let local_x = px - target.rect.x - tokens::SPACE_3;
let local_y = py - target.rect.y - tokens::SPACE_2 + target.scroll_offset_y;
let pos = caret_from_xy(value, local_x, local_y, wrap_width);
if !event.modifiers.shift {
match event.click_count {
2 => {
extend_word_selection(value, selection, pos);
return true;
}
n if n >= 3 => {
extend_line_selection(value, selection, pos);
return true;
}
_ => {}
}
}
selection.head = pos;
true
}
UiEventKind::Click => false,
_ => false,
}
}
fn extend_word_selection(value: &str, selection: &mut TextSelection, pos: usize) {
let (selected_lo, selected_hi) = selection.ordered();
let (word_lo, word_hi) = crate::selection::word_range_at(value, pos);
if pos < selected_lo {
selection.anchor = selected_hi;
selection.head = word_lo;
} else {
selection.anchor = selected_lo;
selection.head = word_hi;
}
}
fn extend_line_selection(value: &str, selection: &mut TextSelection, pos: usize) {
let (selected_lo, selected_hi) = selection.ordered();
let (line_lo, line_hi) = crate::selection::line_range_at(value, pos);
if pos < selected_lo {
selection.anchor = selected_hi;
selection.head = line_lo;
} else {
selection.anchor = selected_lo;
selection.head = line_hi;
}
}
fn move_caret_vertically(
value: &str,
byte_index: usize,
direction: i32,
available_width: Option<f32>,
) -> usize {
let geometry = text_area_geometry(value, available_width);
let (x, y) = geometry.caret_xy(byte_index);
let line_h = geometry.line_height();
let target_y = y + direction as f32 * line_h;
if target_y < -0.5 {
return 0;
}
let probe_y = target_y + line_h * 0.5;
let Some(byte) = geometry.hit_byte(x, probe_y) else {
return value.len();
};
byte
}
fn page_line_delta_for_event(event: &UiEvent, direction: i32) -> i32 {
let visible_h = event
.target
.as_ref()
.map(|target| (target.rect.h - 2.0 * tokens::SPACE_2).max(line_height_px()))
.unwrap_or(line_height_px() * 10.0);
let lines = (visible_h / line_height_px()).floor().max(1.0) as i32;
direction * lines
}
#[track_caller]
pub fn caret_byte_at(value: &str, event: &UiEvent) -> Option<usize> {
caret_byte_at_with_width(value, event, wrap_width_for_event(event))
}
fn caret_byte_at_with_width(
value: &str,
event: &UiEvent,
available_width: Option<f32>,
) -> Option<usize> {
let (px, py) = event.pointer?;
let target = event.target.as_ref()?;
let local_x = px - target.rect.x - tokens::SPACE_3;
let local_y = py - target.rect.y - tokens::SPACE_2 + target.scroll_offset_y;
Some(caret_from_xy(value, local_x, local_y, available_width))
}
fn caret_from_xy(value: &str, x: f32, y: f32, available_width: Option<f32>) -> usize {
let geometry = text_area_geometry(value, available_width);
let line_h = geometry.line_height();
let probe_y = y.max(line_h * 0.5);
let Some(byte) = geometry.hit_byte(x.max(0.0), probe_y) else {
return value.len();
};
byte
}
fn visual_line_range(
value: &str,
byte_index: usize,
available_width: Option<f32>,
) -> (usize, usize) {
metrics::visual_line_byte_range(
value,
byte_index,
tokens::TEXT_SM.size,
FontWeight::Regular,
if available_width.is_some() {
TextWrap::Wrap
} else {
TextWrap::NoWrap
},
available_width,
)
}
fn wrap_width_for_event(event: &UiEvent) -> Option<f32> {
event
.target
.as_ref()
.map(|target| (target.rect.w - 2.0 * tokens::SPACE_3).max(1.0))
}
fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
let mut idx = idx.min(s.len());
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
idx
}
fn prev_char_boundary(s: &str, from: usize) -> usize {
let mut i = from.saturating_sub(1);
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn next_char_boundary(s: &str, from: usize) -> usize {
let mut i = (from + 1).min(s.len());
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, KeyPress};
const TEST_KEY: &str = "ta";
fn text_area(value: &str, sel: TextSelection) -> El {
super::text_area(value, &as_selection(sel), TEST_KEY)
}
fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
let mut g = as_selection(*sel);
let changed = super::apply_event(value, &mut g, TEST_KEY, event);
match g.within(TEST_KEY) {
Some(view) => *sel = view,
None => *sel = TextSelection::default(),
}
changed
}
fn as_selection(sel: TextSelection) -> Selection {
Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
head: SelectionPoint::new(TEST_KEY, sel.head),
}),
}
}
fn ev_key(key: UiKey) -> UiEvent {
ev_key_with_mods(key, KeyModifiers::default())
}
fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
UiEvent {
path: None,
key: None,
target: None,
pointer: None,
key_press: Some(KeyPress {
key,
modifiers,
repeat: false,
}),
text: None,
selection: None,
modifiers,
click_count: 0,
kind: UiEventKind::KeyDown,
}
}
fn ev_key_with_target(key: UiKey, target: crate::event::UiTarget) -> UiEvent {
UiEvent {
path: None,
key: Some(target.key.clone()),
target: Some(target),
pointer: None,
key_press: Some(KeyPress {
key,
modifiers: KeyModifiers::default(),
repeat: false,
}),
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::KeyDown,
}
}
fn ev_key_with_mods_and_target(
key: UiKey,
modifiers: KeyModifiers,
target: crate::event::UiTarget,
) -> UiEvent {
UiEvent {
path: None,
key: Some(target.key.clone()),
target: Some(target),
pointer: None,
key_press: Some(KeyPress {
key,
modifiers,
repeat: false,
}),
text: None,
selection: None,
modifiers,
click_count: 0,
kind: UiEventKind::KeyDown,
}
}
fn ta_target() -> crate::event::UiTarget {
crate::event::UiTarget {
key: "ta".to_string(),
node_id: "/ta".to_string(),
rect: crate::tree::Rect::new(0.0, 0.0, 200.0, 100.0),
tooltip: None,
scroll_offset_y: 0.0,
}
}
fn ev_pointer_down_with_count(
local: (f32, f32),
modifiers: KeyModifiers,
click_count: u8,
) -> UiEvent {
let target = ta_target();
let pointer = (
target.rect.x + tokens::SPACE_3 + local.0,
target.rect.y + tokens::SPACE_2 + local.1,
);
UiEvent {
path: None,
key: Some(target.key.clone()),
target: Some(target),
pointer: Some(pointer),
key_press: None,
text: None,
selection: None,
modifiers,
click_count,
kind: UiEventKind::PointerDown,
}
}
fn ev_drag_with_count(local: (f32, f32), click_count: u8) -> UiEvent {
let target = ta_target();
let pointer = (
target.rect.x + tokens::SPACE_3 + local.0,
target.rect.y + tokens::SPACE_2 + local.1,
);
UiEvent {
path: None,
key: Some(target.key.clone()),
target: Some(target),
pointer: Some(pointer),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count,
kind: UiEventKind::Drag,
}
}
fn ev_middle_click(local: (f32, f32), text: Option<&str>) -> UiEvent {
let target = ta_target();
let pointer = (
target.rect.x + tokens::SPACE_3 + local.0,
target.rect.y + tokens::SPACE_2 + local.1,
);
UiEvent {
path: None,
key: Some(target.key.clone()),
target: Some(target),
pointer: Some(pointer),
key_press: None,
text: text.map(str::to_string),
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
kind: UiEventKind::MiddleClick,
}
}
#[test]
fn text_area_declares_text_cursor() {
let el = text_area("hello", TextSelection::caret(0));
assert_eq!(el.cursor, Some(Cursor::Text));
}
#[test]
fn enter_inserts_newline_and_advances_caret() {
let mut value = String::from("hello");
let mut sel = TextSelection::caret(2);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Enter)));
assert_eq!(value, "he\nllo");
assert_eq!(sel, TextSelection::caret(3));
}
#[test]
fn escape_collapses_selection_without_editing() {
let mut value = String::from("hello");
let mut sel = TextSelection::range(1, 4);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
assert_eq!(value, "hello");
assert_eq!(sel, TextSelection::caret(4));
assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
}
#[test]
fn middle_click_inserts_event_text_at_pointer() {
let mut value = String::from("world");
let mut sel = TextSelection::caret(value.len());
let event = ev_middle_click((0.0, tokens::TEXT_SM.line_height * 0.5), Some("hello "));
assert!(apply_event(&mut value, &mut sel, &event));
assert_eq!(value, "hello world");
assert_eq!(sel, TextSelection::caret("hello ".len()));
}
#[test]
fn arrow_down_moves_to_next_line_at_similar_column() {
let mut value = String::from("alpha\nbravo");
let mut sel = TextSelection::caret(2);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowDown)));
assert!(
(8..=10).contains(&sel.head),
"head={} not near column 2 of line 2",
sel.head
);
assert_eq!(sel.anchor, sel.head);
}
#[test]
fn arrow_up_at_top_clamps_to_start() {
let mut value = String::from("alpha\nbravo");
let mut sel = TextSelection::caret(2);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowUp)));
assert_eq!(sel, TextSelection::caret(0));
}
#[test]
fn page_up_down_move_by_visible_page() {
let mut value = String::from("a\nb\nc\nd\ne\nf");
let mut sel = TextSelection::caret(0);
let mut target = ta_target();
target.rect.h = line_height_px() * 3.0 + 2.0 * tokens::SPACE_2;
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_target(UiKey::PageDown, target.clone())
));
assert_eq!(sel, TextSelection::caret(6));
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_target(UiKey::PageUp, target)
));
assert_eq!(sel, TextSelection::caret(0));
}
#[test]
fn shift_page_down_extends_selection() {
let mut value = String::from("a\nb\nc\nd");
let mut sel = TextSelection::caret(0);
let mut target = ta_target();
target.rect.h = line_height_px() * 2.0 + 2.0 * tokens::SPACE_2;
let shift = KeyModifiers {
shift: true,
..Default::default()
};
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods_and_target(UiKey::PageDown, shift, target)
));
assert_eq!(sel, TextSelection::range(0, 4));
}
#[test]
fn home_goes_to_current_line_start() {
let mut value = String::from("alpha\nbravo");
let mut sel = TextSelection::caret(8); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
assert_eq!(sel, TextSelection::caret(6));
}
#[test]
fn end_goes_to_current_line_end() {
let mut value = String::from("alpha\nbravo");
let mut sel = TextSelection::caret(7); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
assert_eq!(sel, TextSelection::caret(11));
}
#[test]
fn home_and_end_respect_soft_wrapped_visual_lines() {
let mut value = String::from("alpha beta gamma");
let gamma = value.find("gamma").unwrap();
let mut target = ta_target();
target.rect.w = 80.0;
let mut sel = TextSelection::caret(gamma + 2);
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_target(UiKey::Home, target.clone())
));
assert!(
sel.head > 0 && sel.head <= gamma,
"Home should move to the soft line start near gamma, got {:?}",
sel
);
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_target(UiKey::End, target)
));
assert_eq!(sel, TextSelection::caret(value.len()));
}
#[test]
fn shift_arrow_down_extends_selection_anchor_stays() {
let mut value = String::from("alpha\nbravo");
let mut sel = TextSelection::caret(2);
let mods = KeyModifiers {
shift: true,
..Default::default()
};
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::ArrowDown, mods)
));
assert_eq!(sel.anchor, 2);
assert!(sel.head > 2);
}
#[test]
fn double_click_selects_word_at_caret() {
let mut value = String::from("first second\nthird");
let mut sel = TextSelection::caret(0);
let down = ev_pointer_down_with_count((1.0, 1.0), KeyModifiers::default(), 2);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 0);
assert_eq!(sel.head, 5);
}
#[test]
fn double_click_hold_drag_inside_word_keeps_word_selected() {
let mut value = String::from("first second\nthird");
let mut sel = TextSelection::caret(0);
let down = ev_pointer_down_with_count((1.0, 1.0), KeyModifiers::default(), 2);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel, TextSelection::range(0, 5));
let drag = ev_drag_with_count((2.0, 1.0), 2);
assert!(apply_event(&mut value, &mut sel, &drag));
assert_eq!(sel, TextSelection::range(0, 5));
}
#[test]
fn triple_click_selects_line_around_caret_not_whole_value() {
let mut value = String::from("first line\nsecond line\nthird");
let mut sel = TextSelection::caret(0);
let down = ev_pointer_down_with_count((1.0, 1.0), KeyModifiers::default(), 3);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 0);
assert_eq!(sel.head, 10, "selects 'first line' (excludes the \\n)");
}
#[test]
fn ctrl_or_cmd_text_input_is_dropped() {
let mut value = String::from("first\nsecond");
let mut sel = TextSelection::range(0, value.len());
let ctrl = KeyModifiers {
ctrl: true,
..Default::default()
};
let ev = UiEvent {
path: None,
key: None,
target: None,
pointer: None,
key_press: None,
text: Some("c".into()),
selection: None,
modifiers: ctrl,
click_count: 0,
kind: UiEventKind::TextInput,
};
assert!(!apply_event(&mut value, &mut sel, &ev));
assert_eq!(value, "first\nsecond");
}
#[test]
fn text_area_preserves_newlines_for_multiline_paste() {
let mut value = String::from("alpha");
let mut sel = TextSelection::caret(value.len());
let ev = UiEvent {
path: None,
key: None,
target: None,
pointer: None,
key_press: None,
text: Some("\nbeta\n".into()),
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::TextInput,
};
assert!(apply_event(&mut value, &mut sel, &ev));
assert_eq!(value, "alpha\nbeta\n");
assert_eq!(sel, TextSelection::caret(value.len()));
}
#[test]
fn caret_scroll_request_brings_offscreen_caret_into_view() {
let value = (0..40).map(|i| format!("line {i}\n")).collect::<String>();
let caret_byte = clamp_to_char_boundary(&value, value.len() - 1);
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new(TEST_KEY, caret_byte),
head: SelectionPoint::new(TEST_KEY, caret_byte),
}),
};
let mut root = super::text_area(&value, &sel, TEST_KEY)
.height(Size::Fixed(80.0))
.width(Size::Fixed(240.0));
let req = caret_scroll_request_for(&value, &sel, TEST_KEY)
.expect("selection lives in this area → request emitted");
let mut ui_state = crate::state::UiState::new();
ui_state.push_scroll_requests(vec![req]);
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 240.0, 80.0));
let scroll_id = &root.children[0].computed_id;
let offset = ui_state.scroll_offset(scroll_id);
assert!(
offset > 0.0,
"EnsureVisible should have shifted the scroll past 0 to expose the caret; got {offset}"
);
let metrics = ui_state
.scroll
.metrics
.get(scroll_id)
.expect("metrics written for scroll");
let line_h = line_height_px();
let caret_y = text_area_geometry(&value, None).caret_xy(caret_byte).1;
assert!(
caret_y >= offset && caret_y + line_h <= offset + metrics.viewport_h + 0.5,
"caret y={caret_y} not inside viewport [{offset}, {}]; line_h={line_h}",
offset + metrics.viewport_h
);
}
#[test]
fn caret_scroll_request_returns_none_when_selection_lives_elsewhere() {
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("other", 0),
head: SelectionPoint::new("other", 0),
}),
};
assert!(caret_scroll_request_for("hello\nworld", &sel, TEST_KEY).is_none());
}
#[test]
fn ensure_visible_skips_when_caret_already_inside_viewport() {
let value = (0..40).map(|i| format!("line {i}\n")).collect::<String>();
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new(TEST_KEY, 0),
head: SelectionPoint::new(TEST_KEY, 0),
}),
};
let mut root = super::text_area(&value, &sel, TEST_KEY)
.height(Size::Fixed(80.0))
.width(Size::Fixed(240.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 240.0, 80.0));
let scroll_id = root.children[0].computed_id.clone();
ui_state.scroll.offsets.insert(scroll_id.clone(), 300.0);
let req = caret_scroll_request_for(&value, &sel, TEST_KEY).unwrap();
ui_state.push_scroll_requests(vec![req]);
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 240.0, 80.0));
let after = ui_state.scroll_offset(&scroll_id);
assert!(
after <= 1.0,
"caret above viewport → scroll snaps up to expose it; got {after}"
);
let req2 = caret_scroll_request_for(&value, &sel, TEST_KEY).unwrap();
ui_state.push_scroll_requests(vec![req2]);
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 240.0, 80.0));
let after2 = ui_state.scroll_offset(&scroll_id);
assert_eq!(
after, after2,
"caret already visible → resolver must leave the offset alone"
);
}
#[test]
fn pointer_down_after_scroll_lands_on_the_visible_line_not_content_origin() {
let lines: Vec<String> = (0..40).map(|i| format!("line {i}")).collect();
let value = lines.join("\n");
let mut root = super::text_area(&value, &Selection::default(), TEST_KEY)
.height(Size::Fixed(60.0))
.width(Size::Fixed(200.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 60.0));
let scroll_id = root.children[0].computed_id.clone();
let offset = line_height_px() * 5.0;
ui_state.scroll.offsets.insert(scroll_id.clone(), offset);
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 60.0));
let target = crate::hit_test::hit_test_target(
&root,
&ui_state,
(
tokens::SPACE_3 + 5.0,
tokens::SPACE_2 + line_height_px() * 0.5,
),
)
.expect("click inside text_area should hit it");
assert!(
(target.scroll_offset_y - offset).abs() < 0.5,
"UiTarget.scroll_offset_y={} should reflect the inner scroll's {}",
target.scroll_offset_y,
offset
);
let ev = UiEvent {
path: None,
key: Some(target.key.clone()),
pointer: Some((
target.rect.x + tokens::SPACE_3 + 5.0,
target.rect.y + tokens::SPACE_2 + line_height_px() * 0.5,
)),
target: Some(target),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
kind: UiEventKind::PointerDown,
};
let mut value_mut = value.clone();
let mut sel = Selection::default();
super::apply_event(&mut value_mut, &mut sel, TEST_KEY, &ev);
let view = sel.within(TEST_KEY).expect("apply_event sets selection");
let line5_start = lines[..5].iter().map(|s| s.len() + 1).sum::<usize>();
let line5_end = line5_start + lines[5].len();
assert!(
view.head >= line5_start && view.head <= line5_end,
"PointerDown after a 5-line scroll should land on line 5 \
(bytes [{line5_start}..{line5_end}]); got head={}",
view.head
);
}
#[test]
fn drag_past_bottom_edge_emits_autoscroll_request() {
let target = ta_target();
let ev = UiEvent {
path: None,
key: Some(target.key.clone()),
pointer: Some((
target.rect.x + tokens::SPACE_3 + 10.0,
target.rect.bottom() + 30.0, )),
target: Some(target),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::Drag,
};
let req = drag_autoscroll_request_for(&ev, TEST_KEY)
.expect("drag past bottom edge should produce a scroll request");
match req {
crate::scroll::ScrollRequest::EnsureVisible {
container_key, y, ..
} => {
assert_eq!(container_key, TEST_KEY);
assert!(y > 0.0);
}
other => panic!("expected EnsureVisible, got {other:?}"),
}
}
#[test]
fn drag_inside_viewport_emits_no_autoscroll_request() {
let target = ta_target();
let ev = UiEvent {
path: None,
key: Some(target.key.clone()),
pointer: Some((
target.rect.x + tokens::SPACE_3 + 10.0,
target.rect.y + tokens::SPACE_2 + 20.0,
)),
target: Some(target),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::Drag,
};
assert!(drag_autoscroll_request_for(&ev, TEST_KEY).is_none());
}
#[test]
fn drag_past_top_edge_emits_autoscroll_request_up() {
let mut target = ta_target();
target.scroll_offset_y = 200.0; let ev = UiEvent {
path: None,
key: Some(target.key.clone()),
pointer: Some((target.rect.x + tokens::SPACE_3 + 10.0, target.rect.y - 20.0)),
target: Some(target),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::Drag,
};
let req = drag_autoscroll_request_for(&ev, TEST_KEY)
.expect("drag past top edge should produce a scroll request");
match req {
crate::scroll::ScrollRequest::EnsureVisible { y, .. } => {
assert!(
y < 200.0,
"should ask to expose a y above the current 200 offset"
);
}
other => panic!("expected EnsureVisible, got {other:?}"),
}
}
#[test]
fn drag_autoscroll_returns_none_for_non_drag_events() {
let target = ta_target();
let ev = UiEvent {
path: None,
key: Some(target.key.clone()),
pointer: Some((target.rect.x, target.rect.bottom() + 50.0)),
target: Some(target),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
kind: UiEventKind::PointerDown,
};
assert!(drag_autoscroll_request_for(&ev, TEST_KEY).is_none());
}
#[test]
fn pointer_hit_routes_to_outer_text_area_not_inner_scroll() {
let mut root = super::text_area("body\nwith\nlines", &Selection::default(), TEST_KEY)
.height(Size::Fixed(60.0))
.width(Size::Fixed(200.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 60.0));
let hit = crate::hit_test::hit_test(&root, &ui_state, (100.0, 30.0));
assert_eq!(hit.as_deref(), Some(TEST_KEY));
}
#[test]
fn fixed_height_with_overflow_clips_glyph_run_to_inner_scroll_viewport() {
let long = "lorem ipsum dolor sit amet ".repeat(40);
let mut root = super::text_area(&long, &Selection::default(), TEST_KEY)
.height(Size::Fixed(48.0))
.width(Size::Fixed(200.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 200.0, 48.0));
let ops = crate::draw_ops::draw_ops(&root, &ui_state);
let glyph_scissor = ops
.iter()
.find_map(|op| {
if let crate::DrawOp::GlyphRun { scissor, .. } = op {
*scissor
} else {
None
}
})
.expect("text_area should emit a GlyphRun for the value");
assert!(
glyph_scissor.h <= 48.0 + 0.5,
"glyph scissor h={} should be clipped to the outer 48 px content area",
glyph_scissor.h
);
}
#[test]
fn selection_band_for_soft_wrapped_text_uses_wrapped_line_y() {
let value = "alpha beta gamma";
let start = value.find("gamma").unwrap();
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new(TEST_KEY, start),
head: SelectionPoint::new(TEST_KEY, value.len()),
}),
};
let mut root = super::text_area(value, &sel, TEST_KEY)
.height(Size::Fixed(90.0))
.width(Size::Fixed(80.0));
let mut ui_state = crate::state::UiState::new();
ui_state.current_selection = sel;
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 80.0, 90.0));
let ops = crate::draw_ops::draw_ops(&root, &ui_state);
let band_y = ops
.iter()
.find_map(|op| {
if let crate::DrawOp::Quad { id, rect, .. } = op
&& id.contains("selection-band")
{
Some(rect.y)
} else {
None
}
})
.expect("wrapped text selection should emit a selection band");
assert!(
band_y > tokens::SPACE_2 + 1.0,
"selection band should be below the first visual line, got y={band_y}"
);
}
#[test]
fn renders_as_focusable_capture_keys_surface_wrapping_scroll() {
let el = text_area("foo\nbar", TextSelection::caret(0));
assert!(matches!(el.kind, Kind::Custom("text_area")));
assert!(el.focusable);
assert!(el.capture_keys);
assert!(matches!(el.axis, Axis::Column));
assert_eq!(el.children.len(), 1, "outer wraps the scroll viewport");
let scroll = &el.children[0];
assert!(matches!(scroll.kind, Kind::Scroll));
assert!(scroll.scrollable);
assert!(scroll.key.is_none());
let content = scroll
.children
.first()
.expect("scroll has the overlay content child");
assert!(matches!(content.axis, Axis::Overlay));
}
}