Another project
1use bone_text::{ShapedLine, ShapedText, SourceByteIndex};
2
3use crate::a11y::{AccessNode, Role};
4use crate::frame::{FrameCtx, InteractDeclaration};
5use crate::hit_test::{Interaction, Sense};
6use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton};
7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
8use crate::strings::StringKey;
9use crate::text::{Selection, SelectionAction, request_for};
10use crate::theme::{Border, Step12, StrokeWidth};
11use crate::widget_id::WidgetId;
12
13use super::paint::{LabelText, WidgetPaint};
14use super::visuals::{FieldVisuals, SurfaceVisuals, TextVisuals, push_focus_ring};
15
16const CARET_WIDTH_PX: f32 = 1.0;
17const CARET_VPAD_PX: f32 = 3.0;
18
19pub trait Clipboard {
20 fn read(&self) -> Option<String>;
21 fn write(&mut self, text: String);
22}
23
24#[derive(Default, Clone, Debug, PartialEq, Eq)]
25pub struct MemoryClipboard(Option<String>);
26
27impl Clipboard for MemoryClipboard {
28 fn read(&self) -> Option<String> {
29 self.0.clone()
30 }
31 fn write(&mut self, text: String) {
32 self.0 = Some(text);
33 }
34}
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct TextInputState {
38 pub text: String,
39 pub selection: Selection,
40 pub was_focused: bool,
41 pub drag_anchor: Option<SourceByteIndex>,
42 pub scroll_x: f32,
43}
44
45impl Default for TextInputState {
46 fn default() -> Self {
47 Self {
48 text: String::new(),
49 selection: Selection::caret_at(SourceByteIndex::new(0)),
50 was_focused: false,
51 drag_anchor: None,
52 scroll_x: 0.0,
53 }
54 }
55}
56
57impl TextInputState {
58 #[must_use]
59 pub fn from_text<S: Into<String>>(text: S) -> Self {
60 let text = text.into();
61 let len = text.len();
62 Self {
63 selection: Selection::caret_at(SourceByteIndex::new(len)),
64 text,
65 was_focused: false,
66 drag_anchor: None,
67 scroll_x: 0.0,
68 }
69 }
70}
71
72pub trait TextInputValidation {
73 type Error: Clone + PartialEq;
74
75 fn validate(&self, text: &str) -> Result<(), Self::Error>;
76}
77
78#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
79pub struct AlwaysValid;
80
81impl TextInputValidation for AlwaysValid {
82 type Error = core::convert::Infallible;
83
84 fn validate(&self, _text: &str) -> Result<(), Self::Error> {
85 Ok(())
86 }
87}
88
89#[derive(Copy, Clone, Debug, PartialEq, Eq)]
90pub enum TextInputAction {
91 Insert,
92 DeleteBack,
93 DeleteForward,
94 Cut,
95 Copy,
96 Paste,
97 Move,
98 SelectAll,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct TextInputEdit {
103 pub action: TextInputAction,
104 pub before_text: String,
105 pub after_text: String,
106}
107
108#[derive(Clone, Debug, PartialEq)]
109pub struct TextInputResponse<E> {
110 pub interaction: Interaction,
111 pub edits: Vec<TextInputEdit>,
112 pub error: Option<E>,
113 pub paint: Vec<WidgetPaint>,
114}
115
116pub struct TextInput<'state, V> {
117 pub id: WidgetId,
118 pub rect: LayoutRect,
119 pub placeholder: StringKey,
120 pub state: &'state mut TextInputState,
121 pub disabled: bool,
122 pub validator: V,
123}
124
125#[must_use]
126pub fn show_text_input<V: TextInputValidation, C: Clipboard + ?Sized>(
127 ctx: &mut FrameCtx<'_>,
128 input: TextInput<'_, V>,
129 clipboard: &mut C,
130) -> TextInputResponse<V::Error> {
131 let TextInput {
132 id,
133 rect,
134 placeholder,
135 state,
136 disabled,
137 validator,
138 } = input;
139 clamp_selection_to_text(state);
140 let interactive = !disabled;
141 let interaction = ctx.interact(
142 InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
143 .focusable(interactive)
144 .disabled(!interactive)
145 .a11y(
146 AccessNode::new(Role::TextInput)
147 .with_label(placeholder)
148 .with_disabled(!interactive),
149 ),
150 );
151 let live_focused = ctx.is_focused(id);
152 let role = ctx.theme().typography.body;
153 let mut edits = Vec::new();
154 if interactive && live_focused {
155 edits.extend(drain_edits(ctx, state, clipboard));
156 }
157 let shaped = ctx.shaper.shape(&state.text, request_for(role, None));
158 if interactive {
159 let pointer = ctx.input.pointer.as_ref().map(|p| p.position);
160 apply_pointer_selection(state, rect, &shaped, pointer, interaction);
161 }
162 let error = validator.validate(&state.text).err();
163 update_scroll_x(state, rect, &shaped);
164 let paint = build_paint(
165 ctx,
166 PaintInputs {
167 rect,
168 placeholder,
169 state,
170 disabled,
171 shaped: &shaped,
172 },
173 interaction,
174 live_focused,
175 error.is_some(),
176 );
177 state.was_focused = live_focused;
178 TextInputResponse {
179 interaction,
180 edits,
181 error,
182 paint,
183 }
184}
185
186fn apply_pointer_selection(
187 state: &mut TextInputState,
188 rect: LayoutRect,
189 shaped: &ShapedText,
190 pointer: Option<LayoutPos>,
191 interaction: Interaction,
192) {
193 let primary_held =
194 interaction.pressed() && interaction.pressed_buttons.contains(PointerButton::Primary);
195 if !primary_held {
196 state.drag_anchor = None;
197 return;
198 }
199 let Some(byte) = pointer_byte(pointer, rect, shaped, &state.text, state.scroll_x) else {
200 return;
201 };
202 let idx = SourceByteIndex::new(byte);
203 match state.drag_anchor {
204 None => {
205 state.drag_anchor = Some(idx);
206 state.selection = Selection::caret_at(idx);
207 }
208 Some(anchor) => {
209 state.selection = Selection::ranged(anchor, idx);
210 }
211 }
212}
213
214fn pointer_byte(
215 pointer: Option<LayoutPos>,
216 rect: LayoutRect,
217 shaped: &ShapedText,
218 text: &str,
219 scroll_x: f32,
220) -> Option<usize> {
221 let pointer = pointer?;
222 let line = shaped.lines.first()?;
223 let visible = line.visible_advance_px();
224 let start_x = effective_start_x(rect, visible, scroll_x);
225 let local_x = pointer.x.value() - start_x;
226 Some(byte_at_x(line, text.len(), local_x))
227}
228
229fn effective_start_x(rect: LayoutRect, visible: f32, scroll_x: f32) -> f32 {
230 let rw = rect.size.width.value();
231 let rx = rect.origin.x.value();
232 if visible > rw {
233 rx - scroll_x
234 } else {
235 rx + (rw - visible) * 0.5
236 }
237}
238
239fn update_scroll_x(state: &mut TextInputState, rect: LayoutRect, shaped: &ShapedText) {
240 let Some(line) = shaped.lines.first() else {
241 state.scroll_x = 0.0;
242 return;
243 };
244 let visible = line.visible_advance_px();
245 let rw = rect.size.width.value();
246 if visible <= rw {
247 state.scroll_x = 0.0;
248 return;
249 }
250 let max_scroll = (visible - rw).max(0.0);
251 let prev = state.scroll_x.clamp(0.0, max_scroll);
252 let caret_local = caret_x_at_byte(line, state.text.len(), state.selection.caret().value());
253 state.scroll_x = if caret_local < prev {
254 caret_local.max(0.0)
255 } else if caret_local > prev + rw {
256 (caret_local - rw).clamp(0.0, max_scroll)
257 } else {
258 prev
259 };
260}
261
262fn byte_at_x(line: &ShapedLine, text_len: usize, target_x: f32) -> usize {
263 if target_x <= 0.0 {
264 return 0;
265 }
266 line.runs
267 .iter()
268 .flat_map(|run| {
269 run.glyphs
270 .iter()
271 .map(move |g| (g.cluster, run.origin_x_px + g.x_px, g.advance_px))
272 })
273 .find_map(|(cluster, x, adv)| (target_x < x + adv * 0.5).then_some(cluster.value()))
274 .unwrap_or(text_len)
275}
276
277fn caret_x_at_byte(line: &ShapedLine, text_len: usize, byte: usize) -> f32 {
278 if byte == 0 {
279 return 0.0;
280 }
281 if byte >= text_len {
282 return line.visible_advance_px();
283 }
284 line.runs
285 .iter()
286 .flat_map(|run| {
287 run.glyphs
288 .iter()
289 .map(move |g| (g.cluster.value(), run.origin_x_px + g.x_px))
290 })
291 .find(|(cluster, _)| *cluster >= byte)
292 .map_or_else(|| line.visible_advance_px(), |(_, x)| x)
293}
294
295fn caret_rect(
296 rect: LayoutRect,
297 shaped: &ShapedText,
298 text_len: usize,
299 caret_byte: usize,
300 scroll_x: f32,
301) -> LayoutRect {
302 let visible = shaped
303 .lines
304 .first()
305 .map_or(0.0, ShapedLine::visible_advance_px);
306 let start_x = effective_start_x(rect, visible, scroll_x);
307 let caret_local = shaped
308 .lines
309 .first()
310 .map_or(0.0, |line| caret_x_at_byte(line, text_len, caret_byte));
311 let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0);
312 LayoutRect::new(
313 LayoutPos::new(
314 LayoutPx::saturating(start_x + caret_local),
315 LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX),
316 ),
317 LayoutSize::new(LayoutPx::new(CARET_WIDTH_PX), LayoutPx::new(h)),
318 )
319}
320
321fn selection_rect(
322 rect: LayoutRect,
323 shaped: &ShapedText,
324 text_len: usize,
325 min_byte: usize,
326 max_byte: usize,
327 scroll_x: f32,
328) -> Option<LayoutRect> {
329 if max_byte <= min_byte {
330 return None;
331 }
332 let line = shaped.lines.first()?;
333 let visible = line.visible_advance_px();
334 let start_x = effective_start_x(rect, visible, scroll_x);
335 let min_x = start_x + caret_x_at_byte(line, text_len, min_byte);
336 let max_x = start_x + caret_x_at_byte(line, text_len, max_byte);
337 let width = (max_x - min_x).max(0.0);
338 if width <= 0.0 {
339 return None;
340 }
341 let visible_left = rect.origin.x.value();
342 let visible_right = visible_left + rect.size.width.value();
343 let clipped_min = min_x.max(visible_left);
344 let clipped_max = max_x.min(visible_right);
345 let clipped_width = clipped_max - clipped_min;
346 if clipped_width <= 0.0 {
347 return None;
348 }
349 let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0);
350 Some(LayoutRect::new(
351 LayoutPos::new(
352 LayoutPx::saturating(clipped_min),
353 LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX),
354 ),
355 LayoutSize::new(LayoutPx::new(clipped_width), LayoutPx::new(h)),
356 ))
357}
358
359fn drain_edits<C: Clipboard + ?Sized>(
360 ctx: &mut FrameCtx<'_>,
361 state: &mut TextInputState,
362 clipboard: &mut C,
363) -> Vec<TextInputEdit> {
364 let mut edits = Vec::new();
365 let pending = core::mem::take(&mut ctx.input.keys_pressed);
366 let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| {
367 let classified = classify(event);
368 if matches!(classified, ClassifiedKey::PassThrough) {
369 acc.push(event);
370 return acc;
371 }
372 let before = state.text.clone();
373 match perform(classified, state, clipboard) {
374 Some(action) if always_emit(action) || before != state.text => {
375 edits.push(TextInputEdit {
376 action,
377 before_text: before,
378 after_text: state.text.clone(),
379 });
380 }
381 Some(_) => {}
382 None => acc.push(event),
383 }
384 acc
385 });
386 ctx.input.keys_pressed = unconsumed;
387
388 let committed = core::mem::take(&mut ctx.input.text_committed);
389 if !committed.is_empty() {
390 let before = state.text.clone();
391 insert_text(state, &committed);
392 edits.push(TextInputEdit {
393 action: TextInputAction::Insert,
394 before_text: before,
395 after_text: state.text.clone(),
396 });
397 }
398
399 edits
400}
401
402fn perform<C: Clipboard + ?Sized>(
403 classified: ClassifiedKey,
404 state: &mut TextInputState,
405 clipboard: &mut C,
406) -> Option<TextInputAction> {
407 Some(match classified {
408 ClassifiedKey::Backspace => {
409 delete_back(state);
410 TextInputAction::DeleteBack
411 }
412 ClassifiedKey::Delete => {
413 delete_forward(state);
414 TextInputAction::DeleteForward
415 }
416 ClassifiedKey::Move(motion) => {
417 state.selection = state.selection.apply(&state.text, motion);
418 TextInputAction::Move
419 }
420 ClassifiedKey::SelectAll => {
421 state.selection = Selection::ranged(
422 SourceByteIndex::new(0),
423 SourceByteIndex::new(state.text.len()),
424 );
425 TextInputAction::SelectAll
426 }
427 ClassifiedKey::Copy => {
428 let slice = selected_text(state)?;
429 clipboard.write(slice);
430 TextInputAction::Copy
431 }
432 ClassifiedKey::Cut => {
433 let slice = selected_text(state)?;
434 clipboard.write(slice);
435 delete_selection(state);
436 TextInputAction::Cut
437 }
438 ClassifiedKey::Paste => {
439 let text = clipboard.read()?;
440 insert_text(state, &text);
441 TextInputAction::Paste
442 }
443 ClassifiedKey::PassThrough => unreachable!("filtered above"),
444 })
445}
446
447const fn always_emit(action: TextInputAction) -> bool {
448 matches!(
449 action,
450 TextInputAction::Move
451 | TextInputAction::SelectAll
452 | TextInputAction::Copy
453 | TextInputAction::Cut
454 | TextInputAction::Paste,
455 )
456}
457
458#[derive(Copy, Clone, Debug, PartialEq, Eq)]
459enum ClassifiedKey {
460 Backspace,
461 Delete,
462 Move(SelectionAction),
463 SelectAll,
464 Copy,
465 Cut,
466 Paste,
467 PassThrough,
468}
469
470fn classify(event: KeyEvent) -> ClassifiedKey {
471 let no_alt = !event.modifiers.contains(ModifierMask::ALT);
472 let no_meta = !event.modifiers.contains(ModifierMask::META);
473 let ctrl = event.modifiers.contains(ModifierMask::CTRL);
474 let plain = event.modifiers == ModifierMask::NONE;
475 if let Some(action) = SelectionAction::from_key(event) {
476 return ClassifiedKey::Move(action);
477 }
478 match event.code {
479 KeyCode::Named(NamedKey::Backspace) if plain => ClassifiedKey::Backspace,
480 KeyCode::Named(NamedKey::Delete) if plain => ClassifiedKey::Delete,
481 KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'a' => ClassifiedKey::SelectAll,
482 KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'c' => ClassifiedKey::Copy,
483 KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'x' => ClassifiedKey::Cut,
484 KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'v' => ClassifiedKey::Paste,
485 _ => ClassifiedKey::PassThrough,
486 }
487}
488
489fn insert_text(state: &mut TextInputState, text: &str) {
490 let min = state.selection.min().value();
491 let max = state.selection.max().value();
492 state.text.replace_range(min..max, text);
493 let next = SourceByteIndex::new(min + text.len());
494 state.selection = Selection::caret_at(next);
495}
496
497fn delete_back(state: &mut TextInputState) {
498 if !state.selection.is_empty() {
499 delete_selection(state);
500 return;
501 }
502 let caret = state.selection.caret().value();
503 if caret == 0 {
504 return;
505 }
506 let extended = state.selection.apply(
507 &state.text,
508 SelectionAction::Extend(crate::text::CaretMove::PrevGrapheme),
509 );
510 state.selection = extended;
511 delete_selection(state);
512}
513
514fn delete_forward(state: &mut TextInputState) {
515 if !state.selection.is_empty() {
516 delete_selection(state);
517 return;
518 }
519 if state.selection.caret().value() >= state.text.len() {
520 return;
521 }
522 let extended = state.selection.apply(
523 &state.text,
524 SelectionAction::Extend(crate::text::CaretMove::NextGrapheme),
525 );
526 state.selection = extended;
527 delete_selection(state);
528}
529
530fn delete_selection(state: &mut TextInputState) {
531 let min = state.selection.min().value();
532 let max = state.selection.max().value();
533 state.text.replace_range(min..max, "");
534 state.selection = Selection::caret_at(SourceByteIndex::new(min));
535}
536
537fn selected_text(state: &TextInputState) -> Option<String> {
538 if state.selection.is_empty() {
539 return None;
540 }
541 let min = state.selection.min().value();
542 let max = state.selection.max().value();
543 Some(state.text[min..max].to_owned())
544}
545
546fn clamp_selection_to_text(state: &mut TextInputState) {
547 let anchor = clamp_byte_to_boundary(&state.text, state.selection.anchor().value());
548 let caret = clamp_byte_to_boundary(&state.text, state.selection.caret().value());
549 if anchor != state.selection.anchor().value() || caret != state.selection.caret().value() {
550 state.selection =
551 Selection::ranged(SourceByteIndex::new(anchor), SourceByteIndex::new(caret));
552 }
553}
554
555fn clamp_byte_to_boundary(text: &str, byte: usize) -> usize {
556 let bounded = byte.min(text.len());
557 if text.is_char_boundary(bounded) {
558 return bounded;
559 }
560 (0..bounded)
561 .rev()
562 .find(|&i| text.is_char_boundary(i))
563 .unwrap_or(0)
564}
565
566#[derive(Copy, Clone)]
567struct PaintInputs<'a> {
568 rect: LayoutRect,
569 placeholder: StringKey,
570 state: &'a TextInputState,
571 disabled: bool,
572 shaped: &'a ShapedText,
573}
574
575fn build_paint(
576 ctx: &FrameCtx<'_>,
577 inputs: PaintInputs<'_>,
578 interaction: Interaction,
579 live_focused: bool,
580 has_error: bool,
581) -> Vec<WidgetPaint> {
582 let PaintInputs {
583 rect,
584 placeholder,
585 state,
586 disabled,
587 shaped,
588 } = inputs;
589 let visuals = field_visuals(ctx, disabled, interaction, has_error);
590 let (label, label_color) = if state.text.is_empty() {
591 (LabelText::Key(placeholder), visuals.placeholder)
592 } else {
593 (LabelText::Owned(state.text.clone()), visuals.text.color)
594 };
595 let visible = shaped
596 .lines
597 .first()
598 .map_or(0.0, ShapedLine::visible_advance_px);
599 let label_rect = if visible > rect.size.width.value() {
600 LayoutRect::new(
601 LayoutPos::new(
602 LayoutPx::saturating(rect.origin.x.value() - state.scroll_x),
603 rect.origin.y,
604 ),
605 rect.size,
606 )
607 } else {
608 rect
609 };
610 let mut paint = vec![
611 WidgetPaint::Surface {
612 rect,
613 fill: visuals.surface.fill,
614 border: visuals.surface.border,
615 radius: visuals.surface.radius,
616 elevation: None,
617 },
618 WidgetPaint::Label {
619 rect: label_rect,
620 text: label,
621 color: label_color,
622 role: visuals.text.role,
623 },
624 ];
625 let text_len = state.text.len();
626 if let Some(sel) = selection_rect(
627 rect,
628 shaped,
629 text_len,
630 state.selection.min().value(),
631 state.selection.max().value(),
632 state.scroll_x,
633 ) {
634 paint.push(WidgetPaint::SelectionHighlight {
635 rect: sel,
636 color: visuals.selection,
637 });
638 }
639 if live_focused && !disabled {
640 let caret = caret_rect(
641 rect,
642 shaped,
643 text_len,
644 state.selection.caret().value(),
645 state.scroll_x,
646 );
647 paint.push(WidgetPaint::Caret {
648 rect: caret,
649 color: visuals.caret,
650 });
651 }
652 push_focus_ring(ctx, &mut paint, rect, visuals.surface.radius, live_focused);
653 paint
654}
655
656fn field_visuals(
657 ctx: &FrameCtx<'_>,
658 disabled: bool,
659 interaction: Interaction,
660 has_error: bool,
661) -> FieldVisuals {
662 let neutral = ctx.theme().colors.neutral;
663 let danger = ctx.theme().colors.danger;
664 let radius = ctx.theme().radius.sm;
665 let hovered = interaction.hover();
666 let focused = interaction.focused();
667 let fill = if disabled {
668 neutral.step(Step12::SUBTLE_BG)
669 } else {
670 neutral.step(Step12::APP_BG)
671 };
672 let border_color = if has_error {
673 danger.step(Step12::SOLID)
674 } else if focused {
675 ctx.theme().colors.accent_solid()
676 } else if hovered {
677 neutral.step(Step12::HOVER_BORDER)
678 } else {
679 neutral.step(Step12::BORDER)
680 };
681 let surface = SurfaceVisuals {
682 fill,
683 border: Some(Border {
684 width: StrokeWidth::HAIRLINE,
685 color: border_color,
686 }),
687 radius,
688 elevation: None,
689 };
690 let text_color = if disabled {
691 ctx.theme().colors.text_disabled()
692 } else {
693 ctx.theme().colors.text_primary()
694 };
695 FieldVisuals {
696 surface,
697 text: TextVisuals {
698 color: text_color,
699 role: ctx.theme().typography.body,
700 },
701 placeholder: ctx.theme().colors.text_secondary(),
702 caret: ctx.theme().colors.text_primary(),
703 selection: ctx.theme().cad.selection_primary.with_alpha(0.35),
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use std::sync::Arc;
710
711 use super::{
712 AlwaysValid, Clipboard, MemoryClipboard, TextInput, TextInputAction, TextInputState,
713 TextInputValidation, show_text_input,
714 };
715 use bone_text::SourceByteIndex;
716
717 use crate::focus::FocusManager;
718 use crate::frame::FrameCtx;
719 use crate::hit_test::{HitFrame, HitState};
720 use crate::hotkey::HotkeyTable;
721 use crate::input::{
722 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey,
723 };
724 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
725 use crate::strings::StringKey;
726 use crate::strings::StringTable;
727 use crate::text::Selection;
728 use crate::theme::Theme;
729 use crate::widget_id::{WidgetId, WidgetKey};
730
731 const PLACEHOLDER: StringKey = StringKey::new("text.placeholder");
732
733 fn rect() -> LayoutRect {
734 LayoutRect::new(
735 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
736 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(24.0)),
737 )
738 }
739
740 fn id_widget() -> WidgetId {
741 WidgetId::ROOT.child(WidgetKey::new("textinput"))
742 }
743
744 fn focused_with(
745 state_text: &str,
746 events: Vec<KeyEvent>,
747 ) -> (TextInputState, FocusManager, InputSnapshot) {
748 let state = TextInputState::from_text(state_text);
749 let mut focus = FocusManager::new();
750 focus.register_focusable(id_widget());
751 focus.request_focus(id_widget());
752 focus.end_frame();
753 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
754 input.keys_pressed = events;
755 (state, focus, input)
756 }
757
758 fn run<C: Clipboard, V: TextInputValidation>(
759 state: &mut TextInputState,
760 focus: &mut FocusManager,
761 input: &mut InputSnapshot,
762 clipboard: &mut C,
763 validator: V,
764 ) -> super::TextInputResponse<V::Error> {
765 let theme = Arc::new(Theme::light());
766 let table = HotkeyTable::new();
767 let mut hits = HitFrame::new();
768 let prev = HitState::new();
769 let widget = TextInput {
770 id: id_widget(),
771 rect: rect(),
772 placeholder: PLACEHOLDER,
773 state,
774 disabled: false,
775 validator,
776 };
777 let mut a11y = crate::a11y::AccessTreeBuilder::new();
778 let mut shaper = bone_text::Shaper::new();
779 let mut ctx = FrameCtx::new(
780 theme,
781 input,
782 focus,
783 &table,
784 StringTable::empty(),
785 &mut hits,
786 &prev,
787 &mut a11y,
788 &mut shaper,
789 );
790 show_text_input(&mut ctx, widget, clipboard)
791 }
792
793 fn ctrl_key(c: char) -> KeyEvent {
794 KeyEvent::new(KeyCode::Char(KeyChar::from_char(c)), ModifierMask::CTRL)
795 }
796
797 #[test]
798 fn typing_inserts_at_caret() {
799 let (mut state, mut focus, mut input) = focused_with("ab", vec![]);
800 input.text_committed = "c".to_owned();
801 let mut clipboard = MemoryClipboard::default();
802 let _ = run(
803 &mut state,
804 &mut focus,
805 &mut input,
806 &mut clipboard,
807 AlwaysValid,
808 );
809 assert_eq!(state.text, "abc");
810 assert_eq!(state.selection.caret().value(), 3);
811 }
812
813 #[test]
814 fn backspace_deletes_grapheme_when_no_selection() {
815 let (mut state, mut focus, mut input) = focused_with(
816 "abc",
817 vec![KeyEvent::new(
818 KeyCode::Named(NamedKey::Backspace),
819 ModifierMask::NONE,
820 )],
821 );
822 let mut clipboard = MemoryClipboard::default();
823 let _ = run(
824 &mut state,
825 &mut focus,
826 &mut input,
827 &mut clipboard,
828 AlwaysValid,
829 );
830 assert_eq!(state.text, "ab");
831 }
832
833 #[test]
834 fn delete_removes_grapheme_after_caret() {
835 let (mut state, mut focus, mut input) = focused_with(
836 "abc",
837 vec![KeyEvent::new(
838 KeyCode::Named(NamedKey::Delete),
839 ModifierMask::NONE,
840 )],
841 );
842 state.selection = Selection::caret_at(SourceByteIndex::new(1));
843 let mut clipboard = MemoryClipboard::default();
844 let _ = run(
845 &mut state,
846 &mut focus,
847 &mut input,
848 &mut clipboard,
849 AlwaysValid,
850 );
851 assert_eq!(state.text, "ac");
852 }
853
854 #[test]
855 fn cut_copies_selection_and_removes_it() {
856 let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]);
857 state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
858 let mut clipboard = MemoryClipboard::default();
859 let response = run(
860 &mut state,
861 &mut focus,
862 &mut input,
863 &mut clipboard,
864 AlwaysValid,
865 );
866 assert_eq!(state.text, "ho");
867 assert_eq!(clipboard.read(), Some("ell".to_owned()));
868 assert!(
869 response
870 .edits
871 .iter()
872 .any(|e| e.action == TextInputAction::Cut)
873 );
874 assert!(input.keys_pressed.is_empty(), "Cut chord drained");
875 }
876
877 #[test]
878 fn copy_passes_through_when_no_selection() {
879 let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]);
880 let mut clipboard = MemoryClipboard::default();
881 let _ = run(
882 &mut state,
883 &mut focus,
884 &mut input,
885 &mut clipboard,
886 AlwaysValid,
887 );
888 assert_eq!(
889 input.keys_pressed,
890 vec![ctrl_key('c')],
891 "Copy with no selection preserves chord for outer handler",
892 );
893 }
894
895 #[test]
896 fn cut_passes_through_when_no_selection() {
897 let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]);
898 let mut clipboard = MemoryClipboard::default();
899 let response = run(
900 &mut state,
901 &mut focus,
902 &mut input,
903 &mut clipboard,
904 AlwaysValid,
905 );
906 assert_eq!(state.text, "hello");
907 assert!(response.edits.is_empty());
908 assert_eq!(input.keys_pressed, vec![ctrl_key('x')]);
909 }
910
911 #[test]
912 fn paste_passes_through_when_clipboard_empty() {
913 let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]);
914 let mut clipboard = MemoryClipboard::default();
915 let _ = run(
916 &mut state,
917 &mut focus,
918 &mut input,
919 &mut clipboard,
920 AlwaysValid,
921 );
922 assert_eq!(input.keys_pressed, vec![ctrl_key('v')]);
923 }
924
925 #[test]
926 fn paste_drains_chord_when_clipboard_has_content() {
927 let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]);
928 let mut clipboard = MemoryClipboard::default();
929 clipboard.write("x".to_owned());
930 let _ = run(
931 &mut state,
932 &mut focus,
933 &mut input,
934 &mut clipboard,
935 AlwaysValid,
936 );
937 assert!(
938 input.keys_pressed.is_empty(),
939 "Paste with content drains chord"
940 );
941 }
942
943 #[test]
944 fn copy_does_not_modify_text() {
945 let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]);
946 state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
947 let mut clipboard = MemoryClipboard::default();
948 let _ = run(
949 &mut state,
950 &mut focus,
951 &mut input,
952 &mut clipboard,
953 AlwaysValid,
954 );
955 assert_eq!(state.text, "hello");
956 assert_eq!(clipboard.read(), Some("ell".to_owned()));
957 }
958
959 #[test]
960 fn paste_inserts_clipboard_text() {
961 let (mut state, mut focus, mut input) = focused_with("ho", vec![ctrl_key('v')]);
962 state.selection = Selection::caret_at(SourceByteIndex::new(1));
963 let mut clipboard = MemoryClipboard::default();
964 clipboard.write("ell".to_owned());
965 let _ = run(
966 &mut state,
967 &mut focus,
968 &mut input,
969 &mut clipboard,
970 AlwaysValid,
971 );
972 assert_eq!(state.text, "hello");
973 assert_eq!(state.selection.caret().value(), 4);
974 }
975
976 #[test]
977 fn select_all_then_typing_replaces() {
978 let (mut state, mut focus, mut input) = focused_with("abc", vec![ctrl_key('a')]);
979 let mut clipboard = MemoryClipboard::default();
980 let _ = run(
981 &mut state,
982 &mut focus,
983 &mut input,
984 &mut clipboard,
985 AlwaysValid,
986 );
987 assert_eq!(state.selection.min().value(), 0);
988 assert_eq!(state.selection.max().value(), 3);
989
990 let (_, _, mut input2) = focused_with("dummy", vec![]);
991 input2.text_committed = "Z".to_owned();
992 let _ = run(
993 &mut state,
994 &mut focus,
995 &mut input2,
996 &mut clipboard,
997 AlwaysValid,
998 );
999 assert_eq!(state.text, "Z");
1000 }
1001
1002 #[test]
1003 fn arrow_keys_move_caret_without_changing_text() {
1004 let (mut state, mut focus, mut input) = focused_with(
1005 "abc",
1006 vec![KeyEvent::new(
1007 KeyCode::Named(NamedKey::ArrowLeft),
1008 ModifierMask::NONE,
1009 )],
1010 );
1011 let mut clipboard = MemoryClipboard::default();
1012 let _ = run(
1013 &mut state,
1014 &mut focus,
1015 &mut input,
1016 &mut clipboard,
1017 AlwaysValid,
1018 );
1019 assert_eq!(state.text, "abc");
1020 assert_eq!(state.selection.caret().value(), 2);
1021 }
1022
1023 #[test]
1024 fn validator_surfaces_typed_error() {
1025 struct LimitFour;
1026 #[derive(Clone, Debug, PartialEq, Eq)]
1027 enum Err {
1028 TooLong,
1029 }
1030 impl TextInputValidation for LimitFour {
1031 type Error = Err;
1032 fn validate(&self, text: &str) -> Result<(), Err> {
1033 if text.len() > 4 {
1034 Err(Err::TooLong)
1035 } else {
1036 Ok(())
1037 }
1038 }
1039 }
1040 let (mut state, mut focus, mut input) = focused_with("abcde", vec![]);
1041 let mut clipboard = MemoryClipboard::default();
1042 let response = run(
1043 &mut state,
1044 &mut focus,
1045 &mut input,
1046 &mut clipboard,
1047 LimitFour,
1048 );
1049 assert_eq!(response.error, Some(Err::TooLong));
1050 }
1051
1052 #[test]
1053 fn unfocused_input_does_not_consume_keys() {
1054 let mut state = TextInputState::from_text("ab");
1055 let mut focus = FocusManager::new();
1056 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
1057 let event = KeyEvent::new(KeyCode::Char(KeyChar::from_char('c')), ModifierMask::NONE);
1058 input.keys_pressed.push(event);
1059 let mut clipboard = MemoryClipboard::default();
1060 let _ = run(
1061 &mut state,
1062 &mut focus,
1063 &mut input,
1064 &mut clipboard,
1065 AlwaysValid,
1066 );
1067 assert_eq!(state.text, "ab");
1068 assert_eq!(input.keys_pressed, vec![event]);
1069 }
1070
1071 #[test]
1072 fn ime_committed_text_inserts_into_buffer() {
1073 let (mut state, mut focus, mut input) = focused_with("é", vec![]);
1074 state.selection = Selection::caret_at(SourceByteIndex::new("é".len()));
1075 input.text_committed = "あ".to_owned();
1076 let mut clipboard = MemoryClipboard::default();
1077 let _ = run(
1078 &mut state,
1079 &mut focus,
1080 &mut input,
1081 &mut clipboard,
1082 AlwaysValid,
1083 );
1084 assert_eq!(state.text, "éあ");
1085 }
1086
1087 #[test]
1088 fn printable_keystroke_does_not_insert_via_keys_pressed() {
1089 let (mut state, mut focus, mut input) = focused_with(
1090 "",
1091 vec![KeyEvent::new(
1092 KeyCode::Char(KeyChar::from_char('a')),
1093 ModifierMask::NONE,
1094 )],
1095 );
1096 let mut clipboard = MemoryClipboard::default();
1097 let _ = run(
1098 &mut state,
1099 &mut focus,
1100 &mut input,
1101 &mut clipboard,
1102 AlwaysValid,
1103 );
1104 assert_eq!(
1105 state.text, "",
1106 "printable input is the platform's text_committed responsibility, not keys_pressed",
1107 );
1108 }
1109
1110 #[test]
1111 fn paint_carries_user_text_as_owned_label() {
1112 let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
1113 let mut clipboard = MemoryClipboard::default();
1114 let response = run(
1115 &mut state,
1116 &mut focus,
1117 &mut input,
1118 &mut clipboard,
1119 AlwaysValid,
1120 );
1121 let owned_text = response.paint.iter().find_map(|p| match p {
1122 super::WidgetPaint::Label {
1123 text: super::LabelText::Owned(s),
1124 ..
1125 } => Some(s.clone()),
1126 _ => None,
1127 });
1128 assert_eq!(owned_text.as_deref(), Some("hello"));
1129 }
1130
1131 #[test]
1132 fn empty_input_paints_placeholder_key() {
1133 let (mut state, mut focus, mut input) = focused_with("", vec![]);
1134 let mut clipboard = MemoryClipboard::default();
1135 let response = run(
1136 &mut state,
1137 &mut focus,
1138 &mut input,
1139 &mut clipboard,
1140 AlwaysValid,
1141 );
1142 let key = response.paint.iter().find_map(|p| match p {
1143 super::WidgetPaint::Label {
1144 text: super::LabelText::Key(k),
1145 ..
1146 } => Some(*k),
1147 _ => None,
1148 });
1149 assert_eq!(key, Some(PLACEHOLDER));
1150 }
1151
1152 fn caret_paint_for(text: &str, byte: usize) -> super::WidgetPaint {
1153 let (mut state, mut focus, mut input) = focused_with(text, vec![]);
1154 state.selection = Selection::caret_at(SourceByteIndex::new(byte));
1155 let mut clipboard = MemoryClipboard::default();
1156 let response = run(
1157 &mut state,
1158 &mut focus,
1159 &mut input,
1160 &mut clipboard,
1161 AlwaysValid,
1162 );
1163 let Some(caret) = response
1164 .paint
1165 .iter()
1166 .find(|p| matches!(p, super::WidgetPaint::Caret { .. }))
1167 .cloned()
1168 else {
1169 panic!("focused input must paint a caret");
1170 };
1171 caret
1172 }
1173
1174 #[test]
1175 fn caret_rect_advances_with_byte_offset() {
1176 let super::WidgetPaint::Caret { rect: at_zero, .. } = caret_paint_for("abc", 0) else {
1177 panic!("caret variant expected");
1178 };
1179 let super::WidgetPaint::Caret { rect: at_two, .. } = caret_paint_for("abc", 2) else {
1180 panic!("caret variant expected");
1181 };
1182 let super::WidgetPaint::Caret { rect: at_end, .. } = caret_paint_for("abc", 3) else {
1183 panic!("caret variant expected");
1184 };
1185 assert!(
1186 at_zero.origin.x.value() < at_two.origin.x.value(),
1187 "caret at 0 must sit left of caret at 2",
1188 );
1189 assert!(
1190 at_two.origin.x.value() < at_end.origin.x.value(),
1191 "caret at 2 must sit left of caret at end",
1192 );
1193 assert!(
1194 (at_zero.size.width.value() - super::CARET_WIDTH_PX).abs() < f32::EPSILON,
1195 "caret bar is one pixel wide",
1196 );
1197 }
1198
1199 #[test]
1200 fn selection_rect_spans_only_selected_glyphs() {
1201 let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
1202 state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
1203 let mut clipboard = MemoryClipboard::default();
1204 let response = run(
1205 &mut state,
1206 &mut focus,
1207 &mut input,
1208 &mut clipboard,
1209 AlwaysValid,
1210 );
1211 let Some(selection) = response.paint.iter().find_map(|p| match p {
1212 super::WidgetPaint::SelectionHighlight { rect, .. } => Some(*rect),
1213 _ => None,
1214 }) else {
1215 panic!("non-empty selection must emit a highlight");
1216 };
1217 let widget = rect();
1218 assert!(
1219 selection.size.width.value() > 0.0,
1220 "selection rect must have positive width",
1221 );
1222 assert!(
1223 selection.size.width.value() < widget.size.width.value(),
1224 "selection rect cannot exceed input width for substring",
1225 );
1226 assert!(
1227 selection.origin.x.value() >= widget.origin.x.value(),
1228 "selection cannot start before input rect",
1229 );
1230 }
1231
1232 #[test]
1233 fn empty_selection_emits_no_highlight() {
1234 let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
1235 state.selection = Selection::caret_at(SourceByteIndex::new(2));
1236 let mut clipboard = MemoryClipboard::default();
1237 let response = run(
1238 &mut state,
1239 &mut focus,
1240 &mut input,
1241 &mut clipboard,
1242 AlwaysValid,
1243 );
1244 let any_selection = response
1245 .paint
1246 .iter()
1247 .any(|p| matches!(p, super::WidgetPaint::SelectionHighlight { .. }));
1248 assert!(
1249 !any_selection,
1250 "caret-only selection must not paint a highlight"
1251 );
1252 }
1253
1254 fn run_pointer_drag(
1255 state: &mut TextInputState,
1256 widget_rect: LayoutRect,
1257 positions: &[(f32, bool)],
1258 ) {
1259 use crate::hit_test::{HitFrame, HitState, Sense, resolve};
1260 use crate::input::{PointerButton, PointerButtonMask, PointerSample};
1261 let mut focus = FocusManager::new();
1262 focus.register_focusable(id_widget());
1263 focus.end_frame();
1264 let theme = Arc::new(Theme::light());
1265 let table = HotkeyTable::new();
1266 let mut shaper = bone_text::Shaper::new();
1267 let mut hit_state = HitState::new();
1268 for (x, primary_pressed) in positions {
1269 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1270 snap.pointer = Some(PointerSample::new(LayoutPos::new(
1271 LayoutPx::new(*x),
1272 LayoutPx::new(widget_rect.origin.y.value() + widget_rect.size.height.value() * 0.5),
1273 )));
1274 if *primary_pressed {
1275 snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
1276 } else {
1277 snap.buttons_released = PointerButtonMask::just(PointerButton::Primary);
1278 }
1279 let mut hits = HitFrame::new();
1280 hits.push(crate::hit_test::HitItem {
1281 id: id_widget(),
1282 rect: widget_rect,
1283 sense: Sense::DRAGGABLE,
1284 z: crate::hit_test::ZLayer::BASE,
1285 disabled: false,
1286 active: false,
1287 });
1288 let new_state = resolve(&hit_state, &hits, &snap, focus.focused());
1289 let mut clipboard = MemoryClipboard::default();
1290 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1291 let mut frame_hits = HitFrame::new();
1292 let widget = TextInput {
1293 id: id_widget(),
1294 rect: widget_rect,
1295 placeholder: PLACEHOLDER,
1296 state: &mut *state,
1297 disabled: false,
1298 validator: AlwaysValid,
1299 };
1300 let mut snap_for_ctx = snap;
1301 {
1302 let mut ctx = FrameCtx::new(
1303 Arc::clone(&theme),
1304 &mut snap_for_ctx,
1305 &mut focus,
1306 &table,
1307 StringTable::empty(),
1308 &mut frame_hits,
1309 &new_state,
1310 &mut a11y,
1311 &mut shaper,
1312 );
1313 let _ = show_text_input(&mut ctx, widget, &mut clipboard);
1314 }
1315 hit_state = new_state;
1316 }
1317 }
1318
1319 #[test]
1320 fn press_far_left_places_caret_at_start() {
1321 let widget_rect = LayoutRect::new(
1322 LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(0.0)),
1323 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(24.0)),
1324 );
1325 let mut state = TextInputState::from_text("hello");
1326 state.selection = Selection::caret_at(SourceByteIndex::new(5));
1327 run_pointer_drag(&mut state, widget_rect, &[(22.0, true)]);
1328 assert_eq!(
1329 state.selection.caret().value(),
1330 0,
1331 "press far-left of text origin must place caret at byte 0",
1332 );
1333 }
1334
1335 #[test]
1336 fn press_then_drag_extends_selection() {
1337 let widget_rect = LayoutRect::new(
1338 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
1339 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)),
1340 );
1341 let mut state = TextInputState::from_text("hello world");
1342 state.selection = Selection::caret_at(SourceByteIndex::new(0));
1343 run_pointer_drag(
1344 &mut state,
1345 widget_rect,
1346 &[(0.0, true), (200.0, true), (200.0, false)],
1347 );
1348 assert_eq!(state.selection.min().value(), 0);
1349 assert_eq!(state.selection.max().value(), state.text.len());
1350 assert!(state.drag_anchor.is_none(), "release clears drag anchor");
1351 }
1352
1353 fn run_with_state(
1354 state: &mut TextInputState,
1355 widget_rect: LayoutRect,
1356 ) -> super::TextInputResponse<core::convert::Infallible> {
1357 use crate::hit_test::{HitFrame, HitState};
1358 let mut focus = FocusManager::new();
1359 focus.register_focusable(id_widget());
1360 focus.request_focus(id_widget());
1361 focus.end_frame();
1362 let theme = Arc::new(Theme::light());
1363 let table = HotkeyTable::new();
1364 let mut hits = HitFrame::new();
1365 let prev = HitState::new();
1366 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
1367 let mut clipboard = MemoryClipboard::default();
1368 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1369 let mut shaper = bone_text::Shaper::new();
1370 let widget = TextInput {
1371 id: id_widget(),
1372 rect: widget_rect,
1373 placeholder: PLACEHOLDER,
1374 state: &mut *state,
1375 disabled: false,
1376 validator: AlwaysValid,
1377 };
1378 let mut ctx = FrameCtx::new(
1379 theme,
1380 &mut input,
1381 &mut focus,
1382 &table,
1383 StringTable::empty(),
1384 &mut hits,
1385 &prev,
1386 &mut a11y,
1387 &mut shaper,
1388 );
1389 show_text_input(&mut ctx, widget, &mut clipboard)
1390 }
1391
1392 #[test]
1393 fn scroll_x_stays_zero_when_text_fits() {
1394 let widget_rect = LayoutRect::new(
1395 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1396 LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)),
1397 );
1398 let mut state = TextInputState::from_text("hi");
1399 state.selection = Selection::caret_at(SourceByteIndex::new(2));
1400 let _ = run_with_state(&mut state, widget_rect);
1401 assert!(
1402 state.scroll_x.abs() < f32::EPSILON,
1403 "short text in wide rect must not scroll, got {}",
1404 state.scroll_x,
1405 );
1406 }
1407
1408 #[test]
1409 fn scroll_x_advances_to_keep_caret_visible_at_end_of_long_text() {
1410 let widget_rect = LayoutRect::new(
1411 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1412 LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
1413 );
1414 let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
1415 state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
1416 let _ = run_with_state(&mut state, widget_rect);
1417 assert!(
1418 state.scroll_x > 0.0,
1419 "long text with caret at end must scroll, got {}",
1420 state.scroll_x,
1421 );
1422 }
1423
1424 #[test]
1425 fn scroll_x_resets_when_caret_returns_to_start() {
1426 let widget_rect = LayoutRect::new(
1427 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
1428 LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
1429 );
1430 let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
1431 state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
1432 let _ = run_with_state(&mut state, widget_rect);
1433 let scrolled = state.scroll_x;
1434 assert!(scrolled > 0.0);
1435 state.selection = Selection::caret_at(SourceByteIndex::new(0));
1436 let _ = run_with_state(&mut state, widget_rect);
1437 assert!(
1438 state.scroll_x < scrolled,
1439 "caret at start must scroll back, was {} now {}",
1440 scrolled,
1441 state.scroll_x,
1442 );
1443 }
1444
1445 #[test]
1446 fn caret_rect_stays_inside_widget_rect_when_text_overflows() {
1447 let widget_rect = LayoutRect::new(
1448 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::ZERO),
1449 LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
1450 );
1451 let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
1452 state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
1453 let response = run_with_state(&mut state, widget_rect);
1454 let Some(caret) = response.paint.iter().find_map(|p| match p {
1455 super::WidgetPaint::Caret { rect, .. } => Some(*rect),
1456 _ => None,
1457 }) else {
1458 panic!("focused input must paint a caret");
1459 };
1460 let left = widget_rect.origin.x.value();
1461 let right = left + widget_rect.size.width.value();
1462 assert!(
1463 caret.origin.x.value() >= left - 0.5 && caret.origin.x.value() <= right + 0.5,
1464 "caret x {} must lie in [{}, {}]",
1465 caret.origin.x.value(),
1466 left,
1467 right,
1468 );
1469 }
1470
1471 #[test]
1472 fn drag_after_release_does_not_extend_selection() {
1473 let widget_rect = LayoutRect::new(
1474 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
1475 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)),
1476 );
1477 let mut state = TextInputState::from_text("hello world");
1478 state.selection = Selection::caret_at(SourceByteIndex::new(0));
1479 run_pointer_drag(&mut state, widget_rect, &[(20.0, true), (60.0, false)]);
1480 let after_release = state.selection;
1481 run_pointer_drag(&mut state, widget_rect, &[(180.0, false)]);
1482 assert_eq!(
1483 state.selection, after_release,
1484 "pointer movement after release does not change selection",
1485 );
1486 }
1487
1488 #[test]
1489 fn out_of_bounds_selection_snaps_to_text_length_before_editing() {
1490 let (mut state, mut focus, mut input) = focused_with("ab", vec![]);
1491 state.selection = Selection::caret_at(SourceByteIndex::new(99));
1492 input.text_committed = "c".to_owned();
1493 let mut clipboard = MemoryClipboard::default();
1494 let _ = run(
1495 &mut state,
1496 &mut focus,
1497 &mut input,
1498 &mut clipboard,
1499 AlwaysValid,
1500 );
1501 assert_eq!(state.text, "abc");
1502 assert_eq!(state.selection.caret().value(), 3);
1503 }
1504
1505 #[test]
1506 fn mid_codepoint_selection_snaps_to_grapheme_boundary_before_editing() {
1507 let (mut state, mut focus, mut input) = focused_with("é", vec![]);
1508 state.selection = Selection::caret_at(SourceByteIndex::new(1));
1509 input.text_committed = "x".to_owned();
1510 let mut clipboard = MemoryClipboard::default();
1511 let _ = run(
1512 &mut state,
1513 &mut focus,
1514 &mut input,
1515 &mut clipboard,
1516 AlwaysValid,
1517 );
1518 assert_eq!(state.text, "xé");
1519 assert_eq!(state.selection.caret().value(), "x".len());
1520 }
1521
1522 #[test]
1523 fn delete_back_with_invalid_selection_does_not_panic() {
1524 let (mut state, mut focus, mut input) = focused_with(
1525 "abc",
1526 vec![KeyEvent::new(
1527 KeyCode::Named(NamedKey::Backspace),
1528 ModifierMask::NONE,
1529 )],
1530 );
1531 state.selection = Selection::ranged(SourceByteIndex::new(2), SourceByteIndex::new(99));
1532 let mut clipboard = MemoryClipboard::default();
1533 let _ = run(
1534 &mut state,
1535 &mut focus,
1536 &mut input,
1537 &mut clipboard,
1538 AlwaysValid,
1539 );
1540 assert_eq!(state.text, "ab");
1541 }
1542}