1use std::fmt;
2use std::rc::Rc;
3
4use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, Quad, Rect, Role};
5use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
6use ravenrs_reactive::{Binding, State};
7use ravenrs_style::Semantic;
8
9use crate::context::{BuildCx, DragPhase, NodeId, Paint, QuadPaint, Semantics};
10use crate::{AnyView, ButtonSize, ButtonStyle, IconButton, IconName, View, util};
11
12#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
28pub struct TextField {
29 value: Binding<String>,
30 placeholder: String,
31 icon: Option<IconName>,
32 secure: bool,
33 width: Option<f32>,
34 size: ButtonSize,
35 prefix: Option<AnyView>,
36 suffix: Option<AnyView>,
37 clearable: bool,
38 flat: bool,
39 end_aligned: bool,
40 autofocus: bool,
41 select_on_open: bool,
42 on_submit: Option<Rc<dyn Fn(String)>>,
43 on_leave: Option<Rc<dyn Fn()>>,
44 on_cancel: Option<Rc<dyn Fn()>>,
45 intercept: Option<crate::KeyHandler>,
46 state: util::EditorState,
47 pub(crate) idle_text: Option<String>,
49 pub(crate) caret_follows_end: bool,
51}
52
53impl fmt::Debug for TextField {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("TextField").field("placeholder", &self.placeholder).finish_non_exhaustive()
56 }
57}
58
59#[cfg_attr(feature = "live", ravenrs_live::expose)]
60impl TextField {
61 pub fn new(value: Binding<String>) -> Self {
63 Self {
64 value,
65 placeholder: String::new(),
66 icon: None,
67 secure: false,
68 width: None,
69 size: ButtonSize::Medium,
70 prefix: None,
71 suffix: None,
72 clearable: false,
73 flat: false,
74 end_aligned: false,
75 autofocus: false,
76 select_on_open: false,
77 on_submit: None,
78 on_leave: None,
79 on_cancel: None,
80 intercept: None,
81 state: util::EditorState::default(),
82 idle_text: None,
83 caret_follows_end: false,
84 }
85 }
86
87 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
89 self.placeholder = text.into();
90 self
91 }
92
93 pub fn icon(mut self, icon: IconName) -> Self {
95 self.icon = Some(icon);
96 self
97 }
98
99 pub fn secure(mut self) -> Self {
101 self.secure = true;
102 self
103 }
104
105 pub fn width(mut self, width: f32) -> Self {
107 self.width = Some(width);
108 self
109 }
110
111 pub fn compact(mut self) -> Self {
114 self.size = ButtonSize::Small;
115 self
116 }
117
118 pub fn size(mut self, size: ButtonSize) -> Self {
121 self.size = size;
122 self
123 }
124
125 pub fn prefix(mut self, view: impl View + 'static) -> Self {
127 self.prefix = Some(Box::new(view));
128 self
129 }
130
131 pub fn suffix(mut self, view: impl View + 'static) -> Self {
133 self.suffix = Some(Box::new(view));
134 self
135 }
136
137 pub fn clearable(mut self) -> Self {
140 self.clearable = true;
141 self
142 }
143
144 pub fn flat(mut self) -> Self {
147 self.flat = true;
148 self
149 }
150
151 pub fn end_aligned(mut self) -> Self {
153 self.end_aligned = true;
154 self
155 }
156
157 pub fn autofocus(mut self) -> Self {
159 self.autofocus = true;
160 self
161 }
162
163 pub fn select_on_open(mut self) -> Self {
165 self.select_on_open = true;
166 self
167 }
168
169 pub fn on_cancel(mut self, handler: impl Fn() + 'static) -> Self {
171 self.on_cancel = Some(Rc::new(handler));
172 self
173 }
174
175 pub fn intercept_keys(mut self, handler: crate::KeyHandler) -> Self {
178 self.intercept = Some(handler);
179 self
180 }
181
182 pub fn on_submit(mut self, handler: impl Fn(String) + 'static) -> Self {
184 self.on_submit = Some(Rc::new(handler));
185 self
186 }
187
188 pub fn on_leave(mut self, handler: impl Fn() + 'static) -> Self {
190 self.on_leave = Some(Rc::new(handler));
191 self
192 }
193
194 pub fn disabled(mut self, disabled: bool) -> Self {
196 self.state.disabled = disabled;
197 self
198 }
199
200 pub fn read_only(mut self, read_only: bool) -> Self {
202 self.state.read_only = read_only;
203 self
204 }
205}
206
207pub(crate) fn byte_index(s: &str, char_index: usize) -> usize {
208 s.char_indices().nth(char_index).map_or(s.len(), |(i, _)| i)
209}
210
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub(crate) struct Cursor {
215 pub caret: usize,
216 pub anchor: Option<usize>,
217}
218
219#[cfg_attr(feature = "live", ravenrs_live::expose)]
220impl Cursor {
221 pub fn at(at: usize) -> Self {
223 Self { caret: at, anchor: None }
224 }
225
226 pub fn selection(&self) -> Option<(usize, usize)> {
228 let a = self.anchor?;
229 (a != self.caret).then(|| (a.min(self.caret), a.max(self.caret)))
230 }
231
232 pub(crate) fn move_to(&mut self, to: usize, extend: bool) {
234 if extend {
235 self.anchor.get_or_insert(self.caret);
236 } else {
237 self.anchor = None;
238 }
239 self.caret = to;
240 }
241}
242
243fn is_word(c: char) -> bool {
244 c.is_alphanumeric() || c == '_'
245}
246
247fn word_start(chars: &[char], at: usize) -> usize {
249 let mut i = at.min(chars.len());
250 while i > 0 && !is_word(chars[i - 1]) {
251 i -= 1;
252 }
253 while i > 0 && is_word(chars[i - 1]) {
254 i -= 1;
255 }
256 i
257}
258
259fn word_end(chars: &[char], at: usize) -> usize {
261 let mut i = at.min(chars.len());
262 while i < chars.len() && !is_word(chars[i]) {
263 i += 1;
264 }
265 while i < chars.len() && is_word(chars[i]) {
266 i += 1;
267 }
268 i
269}
270
271pub(crate) fn word_at(text: &str, at: usize) -> (usize, usize) {
273 let chars: Vec<char> = text.chars().collect();
274 let at = at.min(chars.len());
275 let on_word = |i: usize| chars.get(i).is_some_and(|c| is_word(*c));
276 if !on_word(at) && !(at > 0 && on_word(at - 1)) {
277 return (at, (at + 1).min(chars.len()));
278 }
279 let mut start = at;
280 while start > 0 && is_word(chars[start - 1]) {
281 start -= 1;
282 }
283 let mut end = at;
284 while end < chars.len() && is_word(chars[end]) {
285 end += 1;
286 }
287 (start, end)
288}
289
290fn delete_selection(text: &mut String, cursor: &mut Cursor) -> bool {
293 let Some((a, b)) = cursor.selection() else { return false };
294 text.replace_range(byte_index(text, a)..byte_index(text, b), "");
295 *cursor = Cursor::at(a);
296 true
297}
298
299pub(crate) fn insert(text: &mut String, cursor: &mut Cursor, s: &str) {
300 delete_selection(text, cursor);
301 let at = byte_index(text, cursor.caret);
302 text.insert_str(at, s);
303 *cursor = Cursor::at(cursor.caret + s.chars().count());
304}
305
306pub(crate) struct ClipboardAccess<'a> {
308 pub clipboard: &'a crate::Clipboard,
309 pub secure: bool,
311 pub multiline: bool,
313}
314
315pub(crate) fn edit(
321 text: &mut String,
322 cursor: &mut Cursor,
323 key: &Key,
324 modifiers: Modifiers,
325 clipboard: Option<ClipboardAccess<'_>>,
326) -> bool {
327 let chars: Vec<char> = text.chars().collect();
328 let len = chars.len();
329 cursor.caret = cursor.caret.min(len);
330 cursor.anchor = cursor.anchor.map(|a| a.min(len));
331 let shift = modifiers.shift;
332 let word = modifiers.alt || modifiers.ctrl;
333 let command = modifiers.ctrl || modifiers.meta;
334 let multiline = clipboard.as_ref().is_some_and(|access| access.multiline);
335 if command && let Key::Character(c) = key {
336 match c.to_ascii_lowercase().as_str() {
337 "a" => *cursor = Cursor { caret: len, anchor: Some(0) },
338 "c" | "x" => {
339 let Some(access) = clipboard else { return false };
340 let Some((a, b)) = cursor.selection() else { return true };
341 if access.secure {
342 return true;
343 }
344 access.clipboard.write(&chars[a..b].iter().collect::<String>());
345 if c.eq_ignore_ascii_case("x") {
346 delete_selection(text, cursor);
347 }
348 }
349 "v" => {
350 let Some(pasted) = clipboard.and_then(|access| access.clipboard.read()) else { return false };
351 if multiline {
352 insert(text, cursor, &pasted.replace("\r\n", "\n").replace('\r', "\n"));
353 } else {
354 let line = pasted.lines().next().unwrap_or_default();
356 insert(text, cursor, line);
357 }
358 }
359 _ => return false,
360 }
361 return true;
362 }
363 match key {
364 Key::Character(s) if !command => {
366 insert(text, cursor, &s.replace("\r\n", " ").replace(['\n', '\r'], " "))
367 }
368 Key::Space if !command => insert(text, cursor, " "),
369 Key::Backspace | Key::Delete if delete_selection(text, cursor) => {}
370 Key::Backspace if cursor.caret > 0 => {
371 let from = if modifiers.meta {
372 0
373 } else if word {
374 word_start(&chars, cursor.caret)
375 } else {
376 cursor.caret - 1
377 };
378 text.replace_range(byte_index(text, from)..byte_index(text, cursor.caret), "");
379 *cursor = Cursor::at(from);
380 }
381 Key::Delete if cursor.caret < len => {
382 let to = if word { word_end(&chars, cursor.caret) } else { cursor.caret + 1 };
383 text.replace_range(byte_index(text, cursor.caret)..byte_index(text, to), "");
384 *cursor = Cursor::at(cursor.caret);
385 }
386 Key::Backspace | Key::Delete => {}
387 Key::ArrowLeft | Key::ArrowRight => {
388 let left = *key == Key::ArrowLeft;
389 let to = match (cursor.selection(), shift) {
390 (Some((a, b)), false) if !word && !modifiers.meta => {
392 if left {
393 a
394 } else {
395 b
396 }
397 }
398 _ if modifiers.meta => {
399 if left {
400 0
401 } else {
402 len
403 }
404 }
405 _ if word => {
406 if left {
407 word_start(&chars, cursor.caret)
408 } else {
409 word_end(&chars, cursor.caret)
410 }
411 }
412 _ if left => cursor.caret.saturating_sub(1),
413 _ => (cursor.caret + 1).min(len),
414 };
415 cursor.move_to(to, shift);
416 }
417 Key::Home | Key::ArrowUp => cursor.move_to(0, shift),
418 Key::End | Key::ArrowDown => cursor.move_to(len, shift),
419 _ => return false,
420 }
421 true
422}
423
424pub(crate) fn index_at(boundaries: &[f32], x: f32) -> usize {
427 let after = boundaries.partition_point(|b| *b < x);
428 match after {
429 0 => 0,
430 n if n >= boundaries.len() => boundaries.len().saturating_sub(1),
431 n => {
432 if x - boundaries[n - 1] < boundaries[n] - x {
433 n - 1
434 } else {
435 n
436 }
437 }
438 }
439}
440
441impl View for TextField {
442 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
443 let theme = *cx.theme();
444 let cursor: State<Cursor> = cx.use_state(|| {
445 if self.select_on_open {
446 Cursor { caret: self.value.get().chars().count(), anchor: Some(0) }
447 } else {
448 Cursor::at(usize::MAX)
449 }
450 });
451 let focus_requested: State<bool> = cx.use_state(|| false);
452 let state = self.state;
453 if self.autofocus && !state.disabled && !*focus_requested.peek() {
454 focus_requested.set_if_changed(true);
455 cx.request_focus();
456 }
457 let refocus: State<bool> = cx.use_state(|| false);
459 if *refocus.peek() {
460 refocus.set(false);
461 cx.request_focus();
462 }
463 let focused = cx.is_focused() && !state.disabled;
464 let had_focus: State<bool> = cx.use_state(|| false);
465 if *had_focus.peek() != focused {
466 had_focus.set(focused);
467 if !focused && let Some(left) = &self.on_leave {
468 left();
469 }
470 }
471 let hovered = cx.is_hovered() && !state.disabled;
472 let text = self.value.get();
473 let len = text.chars().count();
474 let cur = cursor.get();
475 let caret_at = cur.caret.min(len);
476 let visible = self.idle_text.as_ref().filter(|_| !focused).unwrap_or(&text);
477 let shown: String = if self.secure { "•".repeat(len) } else { visible.clone() };
478 let dense = matches!(self.size, ButtonSize::Small | ButtonSize::Mini);
479 let attrs = util::attrs(&theme, self.size.text_style(), None);
480
481 let mut children = Vec::new();
482 if let Some(icon) = self.icon {
483 let size = if self.size == ButtonSize::Mini { 12.0 } else { 16.0 };
484 children.push(util::icon(cx, icon, size, state.ink(theme.color(Semantic::InkMuted))));
485 }
486 if let Some(prefix) = &self.prefix {
488 children.push(cx.keyed_child(1, prefix));
489 }
490 let boundaries: Rc<Vec<f32>> = Rc::new(if shown.chars().count() <= 512 {
492 let chars: Vec<char> = shown.chars().collect();
493 (0..=chars.len())
494 .map(|i| cx.measure_text(&chars[..i].iter().collect::<String>(), &attrs).width)
495 .collect()
496 } else {
497 let w = cx.measure_text(&shown, &attrs).width;
499 let n = shown.chars().count();
500 (0..=n).map(|i| w * i as f32 / n as f32).collect()
501 });
502 let caret_x = boundaries.get(caret_at).copied().unwrap_or(0.0);
503 let selection = cur.selection().map(|(a, b)| {
504 let at = |i: usize| boundaries.get(i.min(len)).copied().unwrap_or(0.0);
505 (at(a), at(b))
506 });
507 let highlight = theme.color(Semantic::Accent).with_alpha(if focused { 0.32 } else { 0.16 });
508 let content = if shown.is_empty() {
509 cx.text(
510 &self.placeholder,
511 &attrs,
512 state.ink(theme.color(Semantic::InkMuted)),
513 LayoutStyle::leaf(),
514 )
515 } else {
516 cx.text(&shown, &attrs, state.ink(theme.color(Semantic::Ink)), LayoutStyle::leaf())
517 };
518 let accent = theme.color(Semantic::Accent);
519 let line = attrs.line_height;
520 let end_aligned = self.end_aligned;
521 let text_width = boundaries.last().copied().unwrap_or(0.0);
522 let inset = move |width: f32| if end_aligned { (width - text_width).max(0.0) } else { 0.0 };
524 let text_area = cx.container(
525 LayoutStyle {
526 grow: 1.0,
527 min_width: Some(0.0),
528 align: Align::Center,
529 justify: if end_aligned { Justify::End } else { Justify::Start },
530 overflow_hidden: true,
531 ..LayoutStyle::stack(Axis::Horizontal)
532 },
533 &[content],
534 Paint::Custom(Rc::new(move |f: Rect, scene| {
535 let y = f.center().y - line / 2.0 + 2.0;
536 let start = f.x() + inset(f.width());
537 if let Some((a, b)) = selection {
538 scene.push_quad(
539 Quad::new(Rect::new(start + a, y, b - a, line - 4.0), highlight).radius(2.0),
540 );
541 }
542 if focused && selection.is_none() && state.accepts_input() {
543 let x = (start + caret_x).min(f.max_x() - 1.5);
544 scene.push_quad(Quad::new(Rect::new(x, y, 1.5, line - 4.0), accent).radius(0.75));
545 }
546 })),
547 );
548 cx.clip(text_area);
549 if !state.disabled {
551 let (drag_cursor, mods, b) = (cursor.clone(), cx.modifiers(), boundaries.clone());
552 cx.on_drag(
553 text_area,
554 Rc::new(move |d| {
555 let at = index_at(&b, d.position.x - d.frame.x() - inset(d.frame.width()));
556 match d.phase {
557 DragPhase::Start => drag_cursor.update(|c| {
558 if mods.get().shift {
559 c.move_to(at, true);
560 } else {
561 *c = Cursor { caret: at, anchor: Some(at) };
562 }
563 }),
564 DragPhase::Move => drag_cursor.update(|c| c.move_to(at, true)),
565 _ => {}
566 }
567 }),
568 );
569 let (word_cursor, word_value) = (cursor.clone(), self.value.clone());
570 cx.on_double_click(
571 text_area,
572 Rc::new(move || {
573 let caret = word_cursor.get().caret;
574 let (a, b) = word_at(&word_value.get(), caret);
575 word_cursor.set(Cursor { caret: b, anchor: Some(a) });
576 }),
577 );
578 cx.cursor(text_area, CursorIcon::Text);
579 }
580 children.push(text_area);
581 if self.clearable && !text.is_empty() && state.accepts_input() {
582 let (value, caret, refocus) = (self.value.clone(), cursor.clone(), refocus.clone());
583 let clear = IconButton::new(IconName::X)
584 .label(cx.labels().clear.clone())
585 .style(ButtonStyle::Ghost)
586 .size(ButtonSize::Mini)
587 .on_click(move || {
588 value.set(String::new());
589 caret.set(Cursor::at(usize::MAX));
590 refocus.set(true);
591 });
592 children.push(cx.keyed_child(2, &clear));
593 }
594 if let Some(suffix) = &self.suffix {
595 children.push(cx.keyed_child(3, suffix));
596 }
597
598 let border = if focused {
599 Border { width: 1.5, color: accent }
600 } else {
601 let base = theme.color(Semantic::Border);
602 Border {
603 width: 1.0,
604 color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
605 }
606 };
607 let style = LayoutStyle {
608 padding: Insets::symmetric(0.0, if dense && !self.flat { 8.0 } else { 12.0 }),
609 height: if self.flat { None } else { Some(self.size.height()) },
610 grow: if self.flat { 1.0 } else { 0.0 },
611 width: self.width,
612 min_width: Some(if dense { 0.0 } else { 160.0 }),
613 spacing: 8.0,
614 align: Align::Center,
615 align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
616 ..LayoutStyle::stack(Axis::Horizontal)
617 };
618 let paint = if self.flat {
619 let fill = if hovered && !focused {
620 theme.color(Semantic::Ink).with_alpha(0.05)
621 } else {
622 Color::TRANSPARENT
623 };
624 QuadPaint {
625 corner_radius: theme.radii.sm,
626 border: focused.then_some(border),
627 ..QuadPaint::new(fill)
628 }
629 } else {
630 QuadPaint {
631 corner_radius: theme.radii.md,
632 border: Some(border),
633 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
634 }
635 };
636 let node = cx.container(style, &children, Paint::Quad(paint));
637 crate::motion::control(cx, node);
638 cx.semantics(text_area, Semantics::hidden());
641 let mut semantics = Semantics::new(Role::TextInput).value(shown);
642 if !self.placeholder.is_empty() {
643 semantics = semantics.name(self.placeholder.clone());
644 semantics.fallback_name = true;
645 }
646 cx.semantics(node, state.semantics(semantics));
647 if state.disabled {
648 return node;
649 }
650
651 let (value, submit, cancel) = (self.value.clone(), self.on_submit.clone(), self.on_cancel.clone());
652 let (intercept, clipboard, secure) = (self.intercept.clone(), cx.clipboard(), self.secure);
653 let follows_end = self.caret_follows_end;
654 cx.on_key(
655 node,
656 Rc::new(move |key, modifiers| {
657 if let Some(i) = &intercept
658 && i(key, modifiers)
659 {
660 return true;
661 }
662 if *key == Key::Escape
663 && let Some(cancel) = &cancel
664 {
665 cancel();
666 return true;
667 }
668 if *key == Key::Enter {
669 if let (Some(submit), true) = (&submit, state.accepts_input()) {
670 submit(value.get());
671 }
672 return true;
673 }
674 let mut text = value.get();
675 let mut c = cursor.get();
676 let access = ClipboardAccess { clipboard: &clipboard, secure, multiline: false };
677 let handled = edit(&mut text, &mut c, key, modifiers, Some(access));
678 if handled {
679 let changed = text != value.get();
680 if changed && !state.accepts_input() {
681 return true;
683 }
684 if follows_end && c.caret == text.chars().count() {
685 c.caret = usize::MAX;
686 }
687 cursor.set(c);
688 if changed {
689 value.set(text);
690 }
691 }
692 handled
693 }),
694 );
695 cx.focusable(node, theme.radii.md);
696 cx.cursor(node, CursorIcon::Text);
697 node
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn press(text: &mut String, cursor: &mut Cursor, key: Key, modifiers: Modifiers) -> bool {
706 edit(text, cursor, &key, modifiers, None)
707 }
708
709 const NONE: Modifiers = Modifiers { shift: false, ctrl: false, alt: false, meta: false };
710 const SHIFT: Modifiers = Modifiers { shift: true, ..NONE };
711 const ALT: Modifiers = Modifiers { alt: true, ..NONE };
712 const CMD: Modifiers = Modifiers { meta: true, ..NONE };
713
714 #[test]
715 fn editing_keys() {
716 let (mut text, mut c) = (String::from("héllo"), Cursor::at(usize::MAX));
717 assert!(press(&mut text, &mut c, Key::Backspace, NONE));
718 assert_eq!((text.as_str(), c.caret), ("héll", 4));
719 press(&mut text, &mut c, Key::Home, NONE);
720 press(&mut text, &mut c, Key::ArrowRight, NONE);
721 press(&mut text, &mut c, Key::Delete, NONE);
722 assert_eq!(text, "hll");
723 press(&mut text, &mut c, Key::Character("é".into()), NONE);
724 press(&mut text, &mut c, Key::Space, NONE);
725 assert_eq!((text.as_str(), c.caret), ("hé ll", 3));
726 assert!(!press(&mut text, &mut c, Key::Tab, NONE));
727 }
728
729 #[test]
730 fn shift_selects_and_typing_replaces_the_selection() {
731 let (mut text, mut c) = (String::from("hello world"), Cursor::at(0));
732 for _ in 0..5 {
733 press(&mut text, &mut c, Key::ArrowRight, SHIFT);
734 }
735 assert_eq!(c.selection(), Some((0, 5)));
736 press(&mut text, &mut c, Key::Character("bye".into()), NONE);
737 assert_eq!((text.as_str(), c), ("bye world", Cursor::at(3)));
738 press(&mut text, &mut c, Key::End, SHIFT);
739 press(&mut text, &mut c, Key::Backspace, NONE);
740 assert_eq!(text, "bye");
741 }
742
743 #[test]
744 fn arrows_collapse_a_selection_to_its_side() {
745 let (mut text, mut c) = (String::from("abcdef"), Cursor { caret: 4, anchor: Some(1) });
746 press(&mut text, &mut c, Key::ArrowLeft, NONE);
747 assert_eq!(c, Cursor::at(1));
748 c = Cursor { caret: 1, anchor: Some(4) };
749 press(&mut text, &mut c, Key::ArrowRight, NONE);
750 assert_eq!(c, Cursor::at(4));
751 }
752
753 #[test]
754 fn words_and_line_ends() {
755 let (mut text, mut c) = (String::from("one two three"), Cursor::at(14));
756 press(&mut text, &mut c, Key::ArrowLeft, ALT);
757 assert_eq!(c.caret, 9);
758 press(&mut text, &mut c, Key::ArrowLeft, ALT);
759 assert_eq!(c.caret, 4);
760 press(&mut text, &mut c, Key::ArrowRight, ALT);
761 assert_eq!(c.caret, 7);
762 press(&mut text, &mut c, Key::Backspace, ALT);
763 assert_eq!((text.as_str(), c.caret), ("one three", 4));
764 press(&mut text, &mut c, Key::ArrowRight, CMD);
765 assert_eq!(c.caret, 11);
766 press(&mut text, &mut c, Key::Backspace, CMD);
767 assert_eq!(text, "");
768 assert_eq!(word_at("say hello there", 6), (4, 9));
769 assert_eq!(word_at("a b", 2), (2, 3), "between words a double-click selects one character");
770 }
771
772 #[test]
773 fn select_all_copy_cut_and_paste() {
774 let clipboard = crate::Clipboard::default();
775 let access = || Some(ClipboardAccess { clipboard: &clipboard, secure: false, multiline: false });
776 let (mut text, mut c) = (String::from("draft"), Cursor::at(0));
777 assert!(edit(&mut text, &mut c, &Key::Character("a".into()), CMD, access()));
778 assert_eq!(c.selection(), Some((0, 5)));
779 edit(&mut text, &mut c, &Key::Character("c".into()), CMD, access());
780 assert_eq!(clipboard.read().as_deref(), Some("draft"));
781 edit(&mut text, &mut c, &Key::Character("x".into()), CMD, access());
782 assert_eq!(text, "");
783 clipboard.write("line one\nline two");
784 edit(&mut text, &mut c, &Key::Character("v".into()), CMD, access());
785 assert_eq!((text.as_str(), c.caret), ("line one", 8), "one line in a single-line field");
786 let ctrl = Modifiers { ctrl: true, ..NONE };
787 assert!(edit(&mut text, &mut c, &Key::Character("A".into()), ctrl, access()), "Ctrl works too");
788 }
789
790 #[test]
791 fn passwords_are_never_copied() {
792 let clipboard = crate::Clipboard::default();
793 let (mut text, mut c) = (String::from("secret"), Cursor { caret: 6, anchor: Some(0) });
794 let access = Some(ClipboardAccess { clipboard: &clipboard, secure: true, multiline: false });
795 assert!(edit(&mut text, &mut c, &Key::Character("x".into()), CMD, access));
796 assert_eq!((text.as_str(), clipboard.read()), ("secret", None));
797 }
798
799 #[test]
800 fn unreadable_clipboard_leaves_paste_to_the_platform() {
801 struct WriteOnly;
802 impl crate::ClipboardProvider for WriteOnly {
803 fn read_text(&self) -> Option<String> {
804 None
805 }
806 fn write_text(&self, _: &str) {}
807 }
808 let clipboard = crate::Clipboard::new(WriteOnly);
809 let (mut text, mut c) = (String::new(), Cursor::at(0));
810 let access = Some(ClipboardAccess { clipboard: &clipboard, secure: false, multiline: false });
811 assert!(!edit(&mut text, &mut c, &Key::Character("v".into()), CMD, access));
812 }
813
814 #[test]
815 fn clicks_land_on_the_nearest_boundary() {
816 let b = [0.0, 10.0, 20.0, 30.0];
817 assert_eq!(index_at(&b, -5.0), 0);
818 assert_eq!(index_at(&b, 4.0), 0);
819 assert_eq!(index_at(&b, 6.0), 1);
820 assert_eq!(index_at(&b, 26.0), 3);
821 assert_eq!(index_at(&b, 99.0), 3);
822 }
823}