use std::panic::Location;
use crate::event::{UiEvent, UiEventKind, UiKey};
use crate::style::StyleProfile;
use crate::text::metrics::{self, caret_xy, hit_text, selection_rects};
use crate::tokens;
use crate::tree::*;
use crate::widgets::text::text;
use crate::widgets::text_input::{TextSelection, replace_selection};
#[track_caller]
pub fn text_area(value: &str, selection: TextSelection) -> El {
let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
let lo = anchor.min(head);
let hi = anchor.max(head);
let mut children: Vec<El> = Vec::with_capacity(8);
let rects = selection_rects(
value,
lo,
hi,
tokens::FONT_BASE,
FontWeight::Regular,
TextWrap::NoWrap,
None,
);
for (rx, ry, rw, rh) in rects {
children.push(
El::new(Kind::Custom("text_area_selection"))
.style_profile(StyleProfile::Solid)
.fill(tokens::SELECTION_BG)
.radius(2.0)
.width(Size::Fixed(rw))
.height(Size::Fixed(rh))
.translate(rx, ry),
);
}
children.push(
text(value)
.font_size(tokens::FONT_BASE)
.wrap_text()
.width(Size::Fill(1.0))
.height(Size::Hug),
);
let (caret_x, caret_y) = caret_xy(
value,
head,
tokens::FONT_BASE,
FontWeight::Regular,
TextWrap::NoWrap,
None,
);
children.push(
caret_bar()
.translate(caret_x, caret_y)
.alpha_follows_focused_ancestor(),
);
El::new(Kind::Custom("text_area"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.surface_role(SurfaceRole::Input)
.focusable()
.capture_keys()
.paint_overflow(Sides::all(tokens::FOCUS_RING_WIDTH))
.fill(tokens::BG_MUTED)
.stroke(tokens::BORDER)
.radius(tokens::RADIUS_MD)
.axis(Axis::Overlay)
.align(Align::Start)
.justify(Justify::Start)
.width(Size::Fill(1.0))
.height(Size::Hug)
.padding(Sides::xy(tokens::SPACE_MD, tokens::SPACE_SM))
.children(children)
}
fn caret_bar() -> El {
El::new(Kind::Custom("text_area_caret"))
.style_profile(StyleProfile::Solid)
.fill(tokens::TEXT_FOREGROUND)
.width(Size::Fixed(2.0))
.height(Size::Fixed(line_height_px()))
.radius(1.0)
}
fn line_height_px() -> f32 {
metrics::line_height(tokens::FONT_BASE)
}
pub fn apply_event(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()));
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.is_control()).collect();
if filtered.is_empty() {
return false;
}
replace_selection(value, selection, &filtered);
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::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);
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);
if new == selection.head {
return false;
}
selection.head = new;
if !mods.shift {
selection.anchor = new;
}
true
}
UiKey::Home => {
let line_start = current_line_start(value, selection.head);
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 = current_line_end(value, selection.head);
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_MD;
let local_y = py - target.rect.y - tokens::SPACE_SM;
let pos = caret_from_xy(value, local_x, local_y);
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_MD;
let local_y = py - target.rect.y - tokens::SPACE_SM;
selection.head = caret_from_xy(value, local_x, local_y);
true
}
UiEventKind::Click => false,
_ => false,
}
}
fn move_caret_vertically(value: &str, byte_index: usize, direction: i32) -> usize {
let (x, y) = caret_xy(
value,
byte_index,
tokens::FONT_BASE,
FontWeight::Regular,
TextWrap::NoWrap,
None,
);
let line_h = line_height_px();
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(hit) = hit_text(
value,
tokens::FONT_BASE,
FontWeight::Regular,
TextWrap::NoWrap,
None,
x,
probe_y,
) else {
return value.len();
};
line_position_to_byte(value, hit.line, hit.byte_index)
}
fn caret_from_xy(value: &str, x: f32, y: f32) -> usize {
let line_h = line_height_px();
let probe_y = y.max(line_h * 0.5);
let Some(hit) = hit_text(
value,
tokens::FONT_BASE,
FontWeight::Regular,
TextWrap::NoWrap,
None,
x.max(0.0),
probe_y,
) else {
return value.len();
};
line_position_to_byte(value, hit.line, hit.byte_index)
}
fn line_position_to_byte(value: &str, line: usize, byte_in_line: usize) -> usize {
let mut current_line = 0;
let mut line_start = 0;
for (i, ch) in value.char_indices() {
if current_line == line {
return clamp_to_char_boundary(value, (line_start + byte_in_line).min(value.len()));
}
if ch == '\n' {
current_line += 1;
line_start = i + ch.len_utf8();
}
}
if current_line == line {
return clamp_to_char_boundary(value, (line_start + byte_in_line).min(value.len()));
}
value.len()
}
fn current_line_start(value: &str, byte_index: usize) -> usize {
value[..byte_index.min(value.len())]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0)
}
fn current_line_end(value: &str, byte_index: usize) -> usize {
let from = byte_index.min(value.len());
value[from..]
.find('\n')
.map(|i| from + i)
.unwrap_or(value.len())
}
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};
fn ev_key(key: UiKey) -> UiEvent {
ev_key_with_mods(key, KeyModifiers::default())
}
fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
UiEvent {
key: None,
target: None,
pointer: None,
key_press: Some(KeyPress {
key,
modifiers,
repeat: false,
}),
text: None,
modifiers,
kind: UiEventKind::KeyDown,
}
}
#[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 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 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 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 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 {
key: None,
target: None,
pointer: None,
key_press: None,
text: Some("c".into()),
modifiers: ctrl,
kind: UiEventKind::TextInput,
};
assert!(!apply_event(&mut value, &mut sel, &ev));
assert_eq!(value, "first\nsecond");
}
#[test]
fn renders_as_overlay_with_capture_keys_and_focus_ring() {
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::Overlay));
}
}