use std::borrow::Cow;
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::TextGeometry;
use crate::tokens;
use crate::tree::*;
use crate::widgets::text::text;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextSelection {
pub anchor: usize,
pub head: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MaskMode {
#[default]
None,
Password,
}
const MASK_CHAR: char = '•';
#[derive(Clone, Copy, Debug, Default)]
pub struct TextInputOpts<'a> {
pub placeholder: Option<&'a str>,
pub max_length: Option<usize>,
pub mask: MaskMode,
}
impl<'a> TextInputOpts<'a> {
pub fn placeholder(mut self, p: &'a str) -> Self {
self.placeholder = Some(p);
self
}
pub fn max_length(mut self, n: usize) -> Self {
self.max_length = Some(n);
self
}
pub fn password(mut self) -> Self {
self.mask = MaskMode::Password;
self
}
fn is_masked(&self) -> bool {
!matches!(self.mask, MaskMode::None)
}
}
impl TextSelection {
pub const fn caret(head: usize) -> Self {
Self { anchor: head, head }
}
pub const fn range(anchor: usize, head: usize) -> Self {
Self { anchor, head }
}
pub fn ordered(self) -> (usize, usize) {
(self.anchor.min(self.head), self.anchor.max(self.head))
}
pub fn is_collapsed(self) -> bool {
self.anchor == self.head
}
}
#[track_caller]
pub fn text_input(value: &str, selection: &Selection, key: &str) -> El {
text_input_with(value, selection, key, TextInputOpts::default())
}
#[track_caller]
pub fn text_input_with(
value: &str,
selection: &Selection,
key: &str,
opts: TextInputOpts<'_>,
) -> El {
build_text_input(value, selection.within(key), opts).key(key)
}
#[track_caller]
fn build_text_input(value: &str, view: Option<TextSelection>, opts: TextInputOpts<'_>) -> El {
let selection = view.unwrap_or_default();
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 line_h = line_height_px();
let display = display_str(value, opts.mask);
let geometry = single_line_geometry(&display);
let to_display = |b: usize| original_to_display_byte(value, b, opts.mask);
let head_px = geometry.prefix_width(to_display(head));
let lo_px = geometry.prefix_width(to_display(lo));
let hi_px = geometry.prefix_width(to_display(hi));
let mut children: Vec<El> = Vec::with_capacity(4);
if lo < hi {
children.push(
El::new(Kind::Custom("text_input_selection"))
.style_profile(StyleProfile::Solid)
.fill(tokens::SELECTION_BG)
.dim_fill(tokens::SELECTION_BG_UNFOCUSED)
.radius(2.0)
.width(Size::Fixed(hi_px - lo_px))
.height(Size::Fixed(line_h))
.translate(lo_px, 0.0),
);
}
if value.is_empty()
&& let Some(ph) = opts.placeholder
{
children.push(
text(ph)
.muted()
.width(Size::Hug)
.height(Size::Fixed(line_h)),
);
}
children.push(
text(display.into_owned())
.width(Size::Hug)
.height(Size::Fixed(line_h)),
);
if view.is_some() {
children.push(
caret_bar()
.translate(head_px, 0.0)
.alpha_follows_focused_ancestor()
.blink_when_focused(),
);
}
let inner = El::new(Kind::Group)
.clip()
.width(Size::Fill(1.0))
.height(Size::Fill(1.0))
.layout(move |ctx| {
let x_offset = (head_px - ctx.container.w).max(0.0);
ctx.children
.iter()
.map(|c| {
let (w, h) = (ctx.measure)(c);
let w = match c.width {
Size::Fixed(v) => v,
Size::Hug => w,
Size::Fill(_) => ctx.container.w,
};
let h = match c.height {
Size::Fixed(v) => v,
Size::Hug => h,
Size::Fill(_) => ctx.container.h,
};
let y = ctx.container.y + (ctx.container.h - h) * 0.5;
Rect::new(ctx.container.x - x_offset, y, w, h)
})
.collect()
})
.children(children);
El::new(Kind::Custom("text_input"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::Input)
.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::Overlay)
.align(Align::Start)
.justify(Justify::Center)
.default_width(Size::Fill(1.0))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0))
.child(inner)
}
fn caret_bar() -> El {
El::new(Kind::Custom("text_input_caret"))
.style_profile(StyleProfile::Solid)
.fill(tokens::FOREGROUND)
.width(Size::Fixed(2.0))
.height(Size::Fixed(line_height_px()))
.radius(1.0)
}
fn line_height_px() -> f32 {
tokens::TEXT_SM.line_height
}
fn single_line_geometry(value: &str) -> TextGeometry<'_> {
TextGeometry::new(
value,
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
TextWrap::NoWrap,
None,
)
}
pub fn apply_event(
value: &mut String,
selection: &mut Selection,
key: &str,
event: &UiEvent,
) -> bool {
apply_event_with(value, selection, key, event, &TextInputOpts::default())
}
pub fn apply_event_with(
value: &mut String,
selection: &mut Selection,
key: &str,
event: &UiEvent,
opts: &TextInputOpts<'_>,
) -> bool {
let mut local = selection.within(key).unwrap_or_default();
let changed = fold_event_local(value, &mut local, event, opts);
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,
opts: &TextInputOpts<'_>,
) -> 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;
}
let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
if to_insert.is_empty() {
return false;
}
replace_selection(value, selection, &to_insert);
true
}
UiEventKind::MiddleClick => {
let Some(byte) = caret_byte_at(value, event, opts) else {
return false;
};
*selection = TextSelection::caret(byte);
if let Some(insert) = event.text.as_deref() {
replace_selection_with(value, selection, insert, opts);
}
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::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::Home => {
if selection.head == 0 && (mods.shift || selection.anchor == 0) {
return false;
}
selection.head = 0;
if !mods.shift {
selection.anchor = 0;
}
true
}
UiKey::End => {
let end = value.len();
if selection.head == end && (mods.shift || selection.anchor == end) {
return false;
}
selection.head = end;
if !mods.shift {
selection.anchor = end;
}
true
}
_ => false,
}
}
UiEventKind::PointerDown => {
let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
return false;
};
let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
let pos = caret_from_x(value, local_x, opts.mask);
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 => {
selection.anchor = 0;
selection.head = value.len();
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 viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
let pos = caret_from_x(value, local_x, opts.mask);
if !event.modifiers.shift {
match event.click_count {
2 => {
extend_word_selection(value, selection, pos);
return true;
}
n if n >= 3 => {
selection.anchor = 0;
selection.head = value.len();
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;
}
}
pub fn selected_text(value: &str, selection: TextSelection) -> &str {
let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
&value[anchor.min(head)..anchor.max(head)]
}
pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
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 (lo, hi) = selection.ordered();
value.replace_range(lo..hi, replacement);
let new_caret = lo + replacement.len();
selection.anchor = new_caret;
selection.head = new_caret;
}
pub fn replace_selection_with(
value: &mut String,
selection: &mut TextSelection,
replacement: &str,
opts: &TextInputOpts<'_>,
) -> usize {
let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
let len = clipped.len();
replace_selection(value, selection, &clipped);
len
}
pub fn select_all(value: &str) -> TextSelection {
TextSelection {
anchor: 0,
head: value.len(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardKind {
Copy,
Cut,
Paste,
}
pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
clipboard_request_for(event, &TextInputOpts::default())
}
pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
if event.kind != UiEventKind::KeyDown {
return None;
}
let kp = event.key_press.as_ref()?;
let mods = kp.modifiers;
if mods.alt || mods.shift {
return None;
}
if !(mods.ctrl || mods.logo) {
return None;
}
let UiKey::Character(c) = &kp.key else {
return None;
};
let kind = match c.to_ascii_lowercase().as_str() {
"c" => ClipboardKind::Copy,
"x" => ClipboardKind::Cut,
"v" => ClipboardKind::Paste,
_ => return None,
};
if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
return None;
}
Some(kind)
}
#[track_caller]
pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
let (px, _py) = event.pointer?;
let target = event.target.as_ref()?;
let local_x = px - target.rect.x - tokens::SPACE_3;
Some(caret_from_x(value, local_x, opts.mask))
}
fn current_x_offset(value: &str, head: usize, viewport_w: f32, mask: MaskMode) -> f32 {
if viewport_w <= 0.0 {
return 0.0;
}
let head = clamp_to_char_boundary(value, head.min(value.len()));
let display = display_str(value, mask);
let geometry = single_line_geometry(&display);
let head_display = original_to_display_byte(value, head, mask);
let head_px = geometry.prefix_width(head_display);
(head_px - viewport_w).max(0.0)
}
fn caret_from_x(value: &str, local_x: f32, mask: MaskMode) -> usize {
if value.is_empty() || local_x <= 0.0 {
return 0;
}
let probe = display_str(value, mask);
let local_y = line_height_px() * 0.5;
let geometry = single_line_geometry(&probe);
let display_byte = match geometry.hit_byte(local_x, local_y) {
Some(byte) => byte.min(probe.len()),
None => probe.len(),
};
display_to_original_byte(value, display_byte, mask)
}
fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
match mask {
MaskMode::None => Cow::Borrowed(value),
MaskMode::Password => {
let n = value.chars().count();
let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
for _ in 0..n {
s.push(MASK_CHAR);
}
Cow::Owned(s)
}
}
}
fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
match mask {
MaskMode::None => byte_index.min(value.len()),
MaskMode::Password => {
let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
value[..clamped].chars().count() * MASK_CHAR.len_utf8()
}
}
}
fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
match mask {
MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
MaskMode::Password => {
let scalar_idx = display_byte / MASK_CHAR.len_utf8();
value
.char_indices()
.nth(scalar_idx)
.map(|(i, _)| i)
.unwrap_or(value.len())
}
}
}
fn clip_to_max_length<'a>(
value: &str,
selection: TextSelection,
replacement: &'a str,
max_length: Option<usize>,
) -> Cow<'a, str> {
let Some(max) = max_length else {
return Cow::Borrowed(replacement);
};
let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
let post_other = value[..lo].chars().count() + value[hi..].chars().count();
let allowed = max.saturating_sub(post_other);
if replacement.chars().count() <= allowed {
Cow::Borrowed(replacement)
} else {
Cow::Owned(replacement.chars().take(allowed).collect())
}
}
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, PointerButton, UiTarget};
use crate::layout::layout;
use crate::runtime::RunnerCore;
use crate::state::UiState;
use crate::text::metrics;
const TEST_KEY: &str = "ti";
#[track_caller]
fn text_input(value: &str, sel: TextSelection) -> El {
super::text_input(value, &as_selection(sel), TEST_KEY)
}
#[track_caller]
fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
super::text_input_with(value, &as_selection(sel), TEST_KEY, opts)
}
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);
sync_back(sel, &g);
changed
}
fn apply_event_with(
value: &mut String,
sel: &mut TextSelection,
event: &UiEvent,
opts: &TextInputOpts<'_>,
) -> bool {
let mut g = as_selection(*sel);
let changed = super::apply_event_with(value, &mut g, TEST_KEY, event, opts);
sync_back(sel, &g);
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 sync_back(local: &mut TextSelection, global: &Selection) {
match global.within(TEST_KEY) {
Some(view) => *local = view,
None => *local = TextSelection::default(),
}
}
fn ev_text(s: &str) -> UiEvent {
ev_text_with_mods(s, KeyModifiers::default())
}
fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
UiEvent {
path: None,
key: None,
target: None,
pointer: None,
key_press: None,
text: Some(s.into()),
selection: None,
modifiers,
click_count: 0,
kind: UiEventKind::TextInput,
}
}
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_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
ev_pointer_down_with_count(target, pointer, modifiers, 1)
}
fn ev_pointer_down_with_count(
target: UiTarget,
pointer: (f32, f32),
modifiers: KeyModifiers,
click_count: u8,
) -> UiEvent {
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(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
ev_drag_with_count(target, pointer, 0)
}
fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
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(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
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,
}
}
fn ti_target() -> UiTarget {
UiTarget {
key: "ti".into(),
node_id: "root.text_input[ti]".into(),
rect: Rect::new(20.0, 20.0, 400.0, 36.0),
tooltip: None,
scroll_offset_y: 0.0,
}
}
fn content_children(el: &El) -> &[El] {
assert_eq!(
el.children.len(),
1,
"text_input wraps its content in a single inner group"
);
&el.children[0].children
}
#[test]
fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
let el = text_input("hello", TextSelection::caret(2));
assert!(matches!(el.kind, Kind::Custom("text_input")));
assert!(el.focusable);
assert!(el.capture_keys);
let cs = content_children(&el);
assert_eq!(cs.len(), 2);
assert!(matches!(cs[0].kind, Kind::Text));
assert_eq!(cs[0].text.as_deref(), Some("hello"));
assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
assert!(cs[1].alpha_follows_focused_ancestor);
}
#[test]
fn text_input_declares_text_cursor() {
let el = text_input("hello", TextSelection::caret(0));
assert_eq!(el.cursor, Some(Cursor::Text));
}
#[test]
fn text_input_with_selection_inserts_selection_band_first() {
let el = text_input("hello", TextSelection::range(2, 4));
let cs = content_children(&el);
assert_eq!(cs.len(), 3);
assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
assert_eq!(cs[1].text.as_deref(), Some("hello"));
assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
}
#[test]
fn text_input_caret_translate_advances_with_head() {
use crate::text::metrics::line_width;
let value = "hello";
let head = 3;
let el = text_input(value, TextSelection::caret(head));
let caret = content_children(&el)
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
.expect("caret child");
let expected = line_width(
&value[..head],
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
);
assert!(
(caret.translate.0 - expected).abs() < 0.01,
"caret translate.x = {}, expected {}",
caret.translate.0,
expected
);
}
#[test]
fn text_input_clamps_off_utf8_boundary() {
let el = text_input("é", TextSelection::caret(1));
let cs = content_children(&el);
assert_eq!(cs[0].text.as_deref(), Some("é"));
let caret = cs
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
.expect("caret child");
assert!(caret.translate.0.abs() < 0.01);
}
#[test]
fn selection_band_fill_dims_when_input_unfocused() {
use crate::draw_ops::draw_ops;
use crate::ir::DrawOp;
use crate::shader::UniformValue;
use crate::state::AnimationMode;
use web_time::Instant;
let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
.padding(20.0);
let mut state = UiState::new();
state.set_animation_mode(AnimationMode::Settled);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now());
let unfocused = band_fill(&tree, &state).expect("band quad emitted");
assert_eq!(
(unfocused.r, unfocused.g, unfocused.b),
(
tokens::SELECTION_BG_UNFOCUSED.r,
tokens::SELECTION_BG_UNFOCUSED.g,
tokens::SELECTION_BG_UNFOCUSED.b
),
"unfocused → band rgb is the muted token"
);
let target = state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.expect("ti in focus order")
.clone();
state.set_focus(Some(target));
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now());
let focused = band_fill(&tree, &state).expect("band quad emitted");
assert_eq!(
(focused.r, focused.g, focused.b),
(
tokens::SELECTION_BG.r,
tokens::SELECTION_BG.g,
tokens::SELECTION_BG.b
),
"focused → band rgb is the saturated token"
);
fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
let ops = draw_ops(tree, state);
for op in ops {
if let DrawOp::Quad { id, uniforms, .. } = op
&& id.contains("text_input_selection")
&& let Some(UniformValue::Color(c)) = uniforms.get("fill")
{
return Some(*c);
}
}
None
}
}
#[test]
fn caret_alpha_follows_focus_envelope() {
use crate::draw_ops::draw_ops;
use crate::ir::DrawOp;
use crate::shader::UniformValue;
use crate::state::AnimationMode;
use web_time::Instant;
let mut tree =
crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
let mut state = UiState::new();
state.set_animation_mode(AnimationMode::Settled);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now());
let caret_alpha = caret_fill_alpha(&tree, &state);
assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
let target = state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.expect("ti in focus order")
.clone();
state.set_focus(Some(target));
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now());
let caret_alpha = caret_fill_alpha(&tree, &state);
assert_eq!(
caret_alpha,
Some(255),
"focused → caret fully visible (alpha=255)"
);
fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
let ops = draw_ops(tree, state);
for op in ops {
if let DrawOp::Quad { id, uniforms, .. } = op
&& id.contains("text_input_caret")
&& let Some(UniformValue::Color(c)) = uniforms.get("fill")
{
return Some(c.a);
}
}
None
}
}
#[test]
fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
use crate::state::caret_blink_alpha_for;
use std::time::Duration;
assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
}
#[test]
fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
use crate::draw_ops::draw_ops;
use crate::ir::DrawOp;
use crate::shader::UniformValue;
use crate::state::AnimationMode;
use std::time::Duration;
let mut tree =
crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
let mut state = UiState::new();
state.set_animation_mode(AnimationMode::Live);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
let target = state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.unwrap()
.clone();
state.set_focus(Some(target));
let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
let input_id = tree.children[0].computed_id.clone();
let pin_focus = |state: &mut UiState| {
state.animation.envelopes.insert(
(input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1.0,
);
};
state.tick_visual_animations(&mut tree, activity_at);
pin_focus(&mut state);
assert_eq!(caret_alpha(&tree, &state), Some(255));
state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
pin_focus(&mut state);
assert_eq!(caret_alpha(&tree, &state), Some(0));
state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
pin_focus(&mut state);
assert_eq!(caret_alpha(&tree, &state), Some(255));
fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
for op in draw_ops(tree, state) {
if let DrawOp::Quad { id, uniforms, .. } = op
&& id.contains("text_input_caret")
&& let Some(UniformValue::Color(c)) = uniforms.get("fill")
{
return Some(c.a);
}
}
None
}
}
#[test]
fn caret_blink_resumes_solid_after_selection_change() {
use crate::state::AnimationMode;
use std::time::Duration;
use web_time::Instant;
let mut tree =
crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
let mut state = UiState::new();
state.set_animation_mode(AnimationMode::Live);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
let t0 = Instant::now();
state.bump_caret_activity(t0);
state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
state.bump_caret_activity(t0 + Duration::from_millis(1100));
assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
}
#[test]
fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
use crate::state::AnimationMode;
use web_time::Instant;
let mut tree =
crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
let mut state = UiState::new();
state.set_animation_mode(AnimationMode::Live);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.sync_focus_order(&tree);
let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
assert!(!no_focus, "without focus, blink doesn't request redraws");
let target = state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.unwrap()
.clone();
state.set_focus(Some(target));
let focused = state.tick_visual_animations(&mut tree, Instant::now());
assert!(focused, "focused capture_keys node → tick demands redraws");
}
#[test]
fn apply_text_input_inserts_at_caret_when_collapsed() {
let mut value = String::from("ho");
let mut sel = TextSelection::caret(1);
assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
assert_eq!(value, "hi, to");
assert_eq!(sel, TextSelection::caret(5));
}
#[test]
fn apply_text_input_replaces_selection() {
let mut value = String::from("hello world");
let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
assert_eq!(value, "hello kit");
assert_eq!(sel, TextSelection::caret(9));
}
#[test]
fn apply_backspace_removes_selection_when_non_empty() {
let mut value = String::from("hello world");
let mut sel = TextSelection::range(6, 11);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
assert_eq!(value, "hello ");
assert_eq!(sel, TextSelection::caret(6));
}
#[test]
fn apply_delete_removes_selection_when_non_empty() {
let mut value = String::from("hello world");
let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
assert_eq!(value, "world");
assert_eq!(sel, TextSelection::caret(0));
}
#[test]
fn apply_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 apply_backspace_collapsed_at_start_is_noop() {
let mut value = String::from("hi");
let mut sel = TextSelection::caret(0);
assert!(!apply_event(
&mut value,
&mut sel,
&ev_key(UiKey::Backspace)
));
}
#[test]
fn apply_arrow_walks_utf8_boundaries() {
let mut value = String::from("aé");
let mut sel = TextSelection::caret(0);
apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
assert_eq!(sel.head, 1);
apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
assert_eq!(sel.head, 3);
assert!(!apply_event(
&mut value,
&mut sel,
&ev_key(UiKey::ArrowRight)
));
apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
assert_eq!(sel.head, 1);
}
#[test]
fn apply_arrow_collapses_selection_without_shift() {
let mut value = String::from("hello");
let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
assert_eq!(sel, TextSelection::caret(1));
let mut sel = TextSelection::range(1, 4);
assert!(apply_event(
&mut value,
&mut sel,
&ev_key(UiKey::ArrowRight)
));
assert_eq!(sel, TextSelection::caret(4));
}
#[test]
fn apply_shift_arrow_extends_selection() {
let mut value = String::from("hello");
let mut sel = TextSelection::caret(2);
let shift = KeyModifiers {
shift: true,
..Default::default()
};
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::ArrowRight, shift)
));
assert_eq!(sel, TextSelection::range(2, 3));
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::ArrowRight, shift)
));
assert_eq!(sel, TextSelection::range(2, 4));
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::ArrowLeft, shift)
));
assert_eq!(sel, TextSelection::range(2, 3));
}
#[test]
fn apply_home_end_collapse_or_extend() {
let mut value = String::from("hello");
let mut sel = TextSelection::caret(2);
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
assert_eq!(sel, TextSelection::caret(5));
assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
assert_eq!(sel, TextSelection::caret(0));
let shift = KeyModifiers {
shift: true,
..Default::default()
};
let mut sel = TextSelection::caret(2);
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::End, shift)
));
assert_eq!(sel, TextSelection::range(2, 5));
}
#[test]
fn apply_ctrl_a_selects_all() {
let mut value = String::from("hello");
let mut sel = TextSelection::caret(2);
let ctrl = KeyModifiers {
ctrl: true,
..Default::default()
};
assert!(apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::Character("a".into()), ctrl)
));
assert_eq!(sel, TextSelection::range(0, 5));
assert!(!apply_event(
&mut value,
&mut sel,
&ev_key_with_mods(UiKey::Character("a".into()), ctrl)
));
}
#[test]
fn apply_pointer_down_sets_anchor_and_head() {
let mut value = String::from("hello");
let mut sel = TextSelection::range(0, 5);
let down = ev_pointer_down(
ti_target(),
(ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
KeyModifiers::default(),
);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel, TextSelection::caret(0));
}
#[test]
fn apply_double_click_selects_word_at_caret() {
let mut value = String::from("hello world");
let mut sel = TextSelection::caret(0);
let target = ti_target();
let click_x = target.rect.x
+ tokens::SPACE_3
+ crate::text::metrics::line_width(
"hello w",
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
);
let down = ev_pointer_down_with_count(
target.clone(),
(click_x, target.rect.y + 18.0),
KeyModifiers::default(),
2,
);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 6);
assert_eq!(sel.head, 11);
}
#[test]
fn apply_triple_click_selects_all() {
let mut value = String::from("hello world");
let mut sel = TextSelection::caret(0);
let target = ti_target();
let down = ev_pointer_down_with_count(
target.clone(),
(target.rect.x + 1.0, target.rect.y + 18.0),
KeyModifiers::default(),
3,
);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 0);
assert_eq!(sel.head, value.len());
}
#[test]
fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
let mut value = String::from("hello world");
let mut sel = TextSelection::caret(0);
let target = ti_target();
let click_x = target.rect.x
+ tokens::SPACE_3
+ crate::text::metrics::line_width(
"hello w",
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
);
let shift = KeyModifiers {
shift: true,
..Default::default()
};
let down =
ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 0);
assert!(sel.head > 0 && sel.head < value.len());
}
#[test]
fn apply_shift_pointer_down_only_moves_head() {
let mut value = String::from("hello");
let mut sel = TextSelection::caret(2);
let shift = KeyModifiers {
shift: true,
..Default::default()
};
let down = ev_pointer_down(
ti_target(),
(
ti_target().rect.x + ti_target().rect.w - 4.0,
ti_target().rect.y + 18.0,
),
shift,
);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel.anchor, 2);
assert_eq!(sel.head, value.len());
}
#[test]
fn apply_drag_extends_head_only() {
let mut value = String::from("hello world");
let mut sel = TextSelection::caret(0);
let down = ev_pointer_down(
ti_target(),
(ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
KeyModifiers::default(),
);
apply_event(&mut value, &mut sel, &down);
assert_eq!(sel, TextSelection::caret(0));
let drag = ev_drag(
ti_target(),
(
ti_target().rect.x + ti_target().rect.w - 4.0,
ti_target().rect.y + 18.0,
),
);
assert!(apply_event(&mut value, &mut sel, &drag));
assert_eq!(sel.anchor, 0);
assert_eq!(sel.head, value.len());
}
#[test]
fn double_click_hold_drag_inside_word_keeps_word_selected() {
let mut value = String::from("hello world");
let mut sel = TextSelection::caret(0);
let target = ti_target();
let click_x = target.rect.x
+ tokens::SPACE_3
+ crate::text::metrics::line_width(
"hello w",
tokens::TEXT_SM.size,
FontWeight::Regular,
false,
);
let down = ev_pointer_down_with_count(
target.clone(),
(click_x, target.rect.y + 18.0),
KeyModifiers::default(),
2,
);
assert!(apply_event(&mut value, &mut sel, &down));
assert_eq!(sel, TextSelection::range(6, 11));
let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
assert!(apply_event(&mut value, &mut sel, &drag));
assert_eq!(sel, TextSelection::range(6, 11));
}
#[test]
fn apply_click_is_noop_for_selection() {
let mut value = String::from("hello");
let mut sel = TextSelection::range(0, 5);
let click = UiEvent {
path: None,
key: Some("ti".into()),
target: Some(ti_target()),
pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
kind: UiEventKind::Click,
};
assert!(!apply_event(&mut value, &mut sel, &click));
assert_eq!(sel, TextSelection::range(0, 5));
}
#[test]
fn apply_middle_click_inserts_event_text_at_pointer() {
let mut value = String::from("world");
let mut sel = TextSelection::caret(value.len());
let target = ti_target();
let pointer = (
target.rect.x + tokens::SPACE_3,
target.rect.y + target.rect.h * 0.5,
);
let event = ev_middle_click(target, pointer, Some("hello "));
assert!(apply_event(&mut value, &mut sel, &event));
assert_eq!(value, "hello world");
assert_eq!(sel, TextSelection::caret("hello ".len()));
}
#[test]
fn helpers_selected_text_and_replace_selection() {
let value = String::from("hello world");
let sel = TextSelection::range(6, 11);
assert_eq!(selected_text(&value, sel), "world");
let mut value = value;
let mut sel = sel;
replace_selection(&mut value, &mut sel, "kit");
assert_eq!(value, "hello kit");
assert_eq!(sel, TextSelection::caret(9));
assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
}
#[test]
fn apply_text_input_filters_control_chars() {
let mut value = String::from("hi");
let mut sel = TextSelection::caret(2);
for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
assert!(
!apply_event(&mut value, &mut sel, &ev_text(ctrl)),
"expected {ctrl:?} to be filtered"
);
assert_eq!(value, "hi");
assert_eq!(sel, TextSelection::caret(2));
}
assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
assert_eq!(value, "hiab");
assert_eq!(sel, TextSelection::caret(4));
}
#[test]
fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
let mut value = String::from("hello");
let mut sel = TextSelection::range(0, 5);
let ctrl = KeyModifiers {
ctrl: true,
..Default::default()
};
let cmd = KeyModifiers {
logo: true,
..Default::default()
};
assert!(!apply_event(
&mut value,
&mut sel,
&ev_text_with_mods("c", ctrl)
));
assert_eq!(value, "hello");
assert!(!apply_event(
&mut value,
&mut sel,
&ev_text_with_mods("v", cmd)
));
assert_eq!(value, "hello");
let altgr = KeyModifiers {
ctrl: true,
alt: true,
..Default::default()
};
let mut value = String::from("");
let mut sel = TextSelection::caret(0);
assert!(apply_event(
&mut value,
&mut sel,
&ev_text_with_mods("é", altgr)
));
assert_eq!(value, "é");
}
#[test]
fn text_input_value_emits_a_single_glyph_run() {
use crate::draw_ops::draw_ops;
use crate::ir::DrawOp;
let mut tree =
crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let ops = draw_ops(&tree, &state);
let glyph_runs = ops
.iter()
.filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
.count();
assert_eq!(
glyph_runs, 1,
"value should shape as one run; got {glyph_runs}"
);
}
#[test]
fn clipboard_request_detects_ctrl_c_x_v() {
let ctrl = KeyModifiers {
ctrl: true,
..Default::default()
};
let cases = [
("c", ClipboardKind::Copy),
("C", ClipboardKind::Copy),
("x", ClipboardKind::Cut),
("v", ClipboardKind::Paste),
];
for (ch, expected) in cases {
let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
}
}
#[test]
fn clipboard_request_accepts_cmd_on_macos() {
let logo = KeyModifiers {
logo: true,
..Default::default()
};
let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
}
#[test]
fn clipboard_request_rejects_with_shift_or_alt() {
let e = ev_key_with_mods(
UiKey::Character("c".into()),
KeyModifiers {
ctrl: true,
shift: true,
..Default::default()
},
);
assert_eq!(clipboard_request(&e), None);
let e = ev_key_with_mods(
UiKey::Character("v".into()),
KeyModifiers {
ctrl: true,
alt: true,
..Default::default()
},
);
assert_eq!(clipboard_request(&e), None);
}
#[test]
fn clipboard_request_ignores_other_keys_and_event_kinds() {
let e = ev_key(UiKey::Character("c".into()));
assert_eq!(clipboard_request(&e), None);
let e = ev_key_with_mods(
UiKey::Character("a".into()),
KeyModifiers {
ctrl: true,
..Default::default()
},
);
assert_eq!(clipboard_request(&e), None);
assert_eq!(clipboard_request(&ev_text("c")), None);
}
fn password_opts() -> TextInputOpts<'static> {
TextInputOpts::default().password()
}
#[test]
fn password_input_renders_value_as_bullets_not_plaintext() {
let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
let leaf = content_children(&el)
.iter()
.find(|c| matches!(c.kind, Kind::Text))
.expect("text leaf");
assert_eq!(leaf.text.as_deref(), Some("•••••••"));
}
#[test]
fn password_input_caret_position_uses_masked_widths() {
use crate::text::metrics::line_width;
let value = "abc";
let head = 2;
let el = text_input_with(value, TextSelection::caret(head), password_opts());
let caret = content_children(&el)
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
.expect("caret child");
let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
assert!(
(caret.translate.0 - expected).abs() < 0.01,
"caret translate.x = {}, expected {}",
caret.translate.0,
expected
);
}
#[test]
fn password_pointer_click_maps_back_to_original_byte() {
let mut value = String::from("abcde");
let mut sel = TextSelection::default();
let target = ti_target();
let down = ev_pointer_down(
target.clone(),
(target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
KeyModifiers::default(),
);
assert!(apply_event_with(
&mut value,
&mut sel,
&down,
&password_opts()
));
assert_eq!(sel.head, value.len());
}
#[test]
fn password_pointer_click_with_multibyte_value() {
let mut value = String::from("éé");
let mut sel = TextSelection::default();
let target = ti_target();
let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
let down = ev_pointer_down(
target,
(click_x, ti_target().rect.y + 18.0),
KeyModifiers::default(),
);
assert!(apply_event_with(
&mut value,
&mut sel,
&down,
&password_opts()
));
assert!(
value.is_char_boundary(sel.head),
"head={} not on a char boundary in {value:?}",
sel.head
);
assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
}
#[test]
fn password_clipboard_request_suppresses_copy_and_cut_only() {
let ctrl = KeyModifiers {
ctrl: true,
..Default::default()
};
let opts = password_opts();
let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
assert_eq!(clipboard_request_for(©, &opts), None);
assert_eq!(clipboard_request_for(&cut, &opts), None);
assert_eq!(
clipboard_request_for(&paste, &opts),
Some(ClipboardKind::Paste)
);
let plain = TextInputOpts::default();
assert_eq!(
clipboard_request_for(©, &plain),
Some(ClipboardKind::Copy)
);
}
#[test]
fn placeholder_renders_only_when_value_is_empty() {
let opts = TextInputOpts::default().placeholder("Email");
let empty = text_input_with("", TextSelection::default(), opts);
let muted_leaf = content_children(&empty)
.iter()
.find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
assert!(muted_leaf.is_some(), "placeholder leaf should be present");
let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
let muted_leaf = content_children(&nonempty)
.iter()
.find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
assert!(
muted_leaf.is_none(),
"placeholder should not render once the field has a value"
);
}
#[test]
fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
use crate::tree::Size;
let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
let mut root = super::text_input(
&value,
&as_selection_in("ti", TextSelection::caret(value.len())),
"ti",
)
.width(Size::Fixed(120.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
let inner = &root.children[0];
let text_leaf = inner
.children
.iter()
.find(|c| matches!(c.kind, Kind::Text))
.expect("text leaf");
let leaf_rect = ui_state.rect(&text_leaf.computed_id);
let inner_rect = ui_state.rect(&inner.computed_id);
assert!(
leaf_rect.x < inner_rect.x,
"text leaf rect.x={} should be left of inner rect.x={} after \
horizontal caret-into-view; layout did not shift content",
leaf_rect.x,
inner_rect.x,
);
}
#[test]
fn short_value_does_not_shift_content() {
use crate::tree::Size;
let mut root =
super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
.width(Size::Fixed(120.0));
let mut ui_state = crate::state::UiState::new();
crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
let inner = &root.children[0];
let text_leaf = inner
.children
.iter()
.find(|c| matches!(c.kind, Kind::Text))
.expect("text leaf");
let leaf_rect = ui_state.rect(&text_leaf.computed_id);
let inner_rect = ui_state.rect(&inner.computed_id);
assert!(
(leaf_rect.x - inner_rect.x).abs() < 0.5,
"short value should not shift; got leaf.x={} inner.x={}",
leaf_rect.x,
inner_rect.x
);
}
fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new(key, sel.anchor),
head: SelectionPoint::new(key, sel.head),
}),
}
}
#[test]
fn max_length_truncates_text_input_inserts() {
let mut value = String::from("ab");
let mut sel = TextSelection::caret(2);
let opts = TextInputOpts::default().max_length(4);
assert!(apply_event_with(
&mut value,
&mut sel,
&ev_text("cdef"),
&opts
));
assert_eq!(value, "abcd");
assert_eq!(sel, TextSelection::caret(4));
assert!(!apply_event_with(
&mut value,
&mut sel,
&ev_text("z"),
&opts
));
assert_eq!(value, "abcd");
}
#[test]
fn max_length_replaces_selection_with_capacity_freed_by_removal() {
let mut value = String::from("abc");
let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
assert!(apply_event_with(
&mut value,
&mut sel,
&ev_text("12345"),
&opts
));
assert_eq!(value, "1234");
assert_eq!(sel, TextSelection::caret(4));
}
#[test]
fn replace_selection_with_max_length_clips_a_paste() {
let mut value = String::from("ab");
let mut sel = TextSelection::caret(2);
let opts = TextInputOpts::default().max_length(5);
let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
assert_eq!(value, "ab012");
assert_eq!(inserted, 3);
assert_eq!(sel, TextSelection::caret(5));
}
#[test]
fn max_length_does_not_shrink_an_already_overlong_value() {
let mut value = String::from("abcdef");
let mut sel = TextSelection::caret(6);
let opts = TextInputOpts::default().max_length(3);
assert!(!apply_event_with(
&mut value,
&mut sel,
&ev_text("z"),
&opts
));
assert_eq!(value, "abcdef");
assert!(apply_event_with(
&mut value,
&mut sel,
&ev_key(UiKey::Backspace),
&opts
));
assert_eq!(value, "abcde");
}
#[test]
fn end_to_end_drag_select_through_runner_core() {
let mut value = String::from("hello world");
let mut sel = TextSelection::default();
let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
let mut core = RunnerCore::new();
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
core.ui_state = state;
core.snapshot(&tree, &mut Default::default());
let rect = core.rect_of_key("ti").expect("ti rect");
let down_x = rect.x + 8.0;
let drag_x = rect.x + 80.0;
let cy = rect.y + rect.h * 0.5;
core.pointer_moved(down_x, cy);
let down = core
.pointer_down(down_x, cy, PointerButton::Primary)
.into_iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.expect("pointer_down emits PointerDown");
assert!(apply_event(&mut value, &mut sel, &down));
let drag = core
.pointer_moved(drag_x, cy)
.events
.into_iter()
.find(|e| e.kind == UiEventKind::Drag)
.expect("Drag while pressed");
assert!(apply_event(&mut value, &mut sel, &drag));
let events = core.pointer_up(drag_x, cy, PointerButton::Primary);
for e in &events {
apply_event(&mut value, &mut sel, e);
}
assert!(
!sel.is_collapsed(),
"expected drag-select to leave a non-empty selection"
);
assert_eq!(
sel.anchor, 0,
"anchor should sit at the down position (caret 0)"
);
assert!(
sel.head > 0 && sel.head <= value.len(),
"head={} value.len={}",
sel.head,
value.len()
);
}
#[test]
fn apply_event_writes_back_under_the_inputs_key() {
let mut value = String::new();
let mut sel = Selection::default();
let event = ev_text("h");
assert!(super::apply_event(&mut value, &mut sel, "name", &event));
assert_eq!(value, "h");
let r = sel.range.as_ref().expect("selection set");
assert_eq!(r.anchor.key, "name");
assert_eq!(r.head.key, "name");
assert_eq!(r.head.byte, 1);
}
#[test]
fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
let mut value = String::new();
let mut sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("para-a", 0),
head: SelectionPoint::new("para-a", 5),
}),
};
let event = ev_text("x");
assert!(super::apply_event(&mut value, &mut sel, "email", &event));
assert_eq!(value, "x");
let r = sel.range.as_ref().unwrap();
assert_eq!(r.anchor.key, "email", "selection ownership migrated");
assert_eq!(r.head.byte, 1);
}
#[test]
fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
let mut value = String::from("hi");
let mut sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("para-a", 0),
head: SelectionPoint::new("para-a", 3),
}),
};
let event = ev_key(UiKey::Other("F1".into()));
assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
let r = sel.range.as_ref().unwrap();
assert_eq!(r.anchor.key, "para-a");
assert_eq!(r.head.byte, 3);
}
#[test]
fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
let sel = Selection::caret("name", 2);
let el = super::text_input("hello", &sel, "name");
assert_eq!(el.key.as_deref(), Some("name"));
let caret = content_children(&el)
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
.expect("caret child");
let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
assert!(
(caret.translate.0 - expected).abs() < 0.01,
"caret.x={} expected {}",
caret.translate.0,
expected
);
}
#[test]
fn text_input_omits_caret_when_selection_lives_elsewhere() {
let sel = Selection {
range: Some(SelectionRange {
anchor: SelectionPoint::new("other", 0),
head: SelectionPoint::new("other", 5),
}),
};
let el = super::text_input("hello", &sel, "name");
let band = el
.children
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
assert!(band.is_none(), "no band when selection lives elsewhere");
let caret = el
.children
.iter()
.find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
assert!(
caret.is_none(),
"no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
);
}
}