Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): text input widget

Lewis: May this revision serve well! <lu5a@proton.me>

+1013
+1013
crates/bone-ui/src/widgets/text_input.rs
··· 1 + use bone_text::SourceByteIndex; 2 + 3 + use crate::frame::{FrameCtx, InteractDeclaration}; 4 + use crate::hit_test::{Interaction, Sense}; 5 + use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 6 + use crate::layout::LayoutRect; 7 + use crate::strings::StringKey; 8 + use crate::text::{Selection, SelectionAction}; 9 + use crate::theme::{Border, Step12, StrokeWidth}; 10 + use crate::widget_id::WidgetId; 11 + 12 + use super::paint::{LabelText, SelectionByteRange, WidgetPaint}; 13 + use super::visuals::{FieldVisuals, SurfaceVisuals, TextVisuals, push_focus_ring}; 14 + 15 + pub trait Clipboard { 16 + fn read(&self) -> Option<String>; 17 + fn write(&mut self, text: String); 18 + } 19 + 20 + #[derive(Default, Clone, Debug, PartialEq, Eq)] 21 + pub struct MemoryClipboard(Option<String>); 22 + 23 + impl Clipboard for MemoryClipboard { 24 + fn read(&self) -> Option<String> { 25 + self.0.clone() 26 + } 27 + fn write(&mut self, text: String) { 28 + self.0 = Some(text); 29 + } 30 + } 31 + 32 + #[derive(Clone, Debug, PartialEq, Eq)] 33 + pub struct TextInputState { 34 + pub text: String, 35 + pub selection: Selection, 36 + pub was_focused: bool, 37 + } 38 + 39 + impl Default for TextInputState { 40 + fn default() -> Self { 41 + Self { 42 + text: String::new(), 43 + selection: Selection::caret_at(SourceByteIndex::new(0)), 44 + was_focused: false, 45 + } 46 + } 47 + } 48 + 49 + impl TextInputState { 50 + #[must_use] 51 + pub fn from_text<S: Into<String>>(text: S) -> Self { 52 + let text = text.into(); 53 + let len = text.len(); 54 + Self { 55 + selection: Selection::caret_at(SourceByteIndex::new(len)), 56 + text, 57 + was_focused: false, 58 + } 59 + } 60 + } 61 + 62 + pub trait TextInputValidation { 63 + type Error: Clone + PartialEq; 64 + 65 + fn validate(&self, text: &str) -> Result<(), Self::Error>; 66 + } 67 + 68 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 69 + pub struct AlwaysValid; 70 + 71 + impl TextInputValidation for AlwaysValid { 72 + type Error = core::convert::Infallible; 73 + 74 + fn validate(&self, _text: &str) -> Result<(), Self::Error> { 75 + Ok(()) 76 + } 77 + } 78 + 79 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 80 + pub enum TextInputAction { 81 + Insert, 82 + DeleteBack, 83 + DeleteForward, 84 + Cut, 85 + Copy, 86 + Paste, 87 + Move, 88 + SelectAll, 89 + } 90 + 91 + #[derive(Clone, Debug, PartialEq, Eq)] 92 + pub struct TextInputEdit { 93 + pub action: TextInputAction, 94 + pub before_text: String, 95 + pub after_text: String, 96 + } 97 + 98 + #[derive(Clone, Debug, PartialEq)] 99 + pub struct TextInputResponse<E> { 100 + pub interaction: Interaction, 101 + pub edits: Vec<TextInputEdit>, 102 + pub error: Option<E>, 103 + pub paint: Vec<WidgetPaint>, 104 + } 105 + 106 + pub struct TextInput<'state, V> { 107 + pub id: WidgetId, 108 + pub rect: LayoutRect, 109 + pub placeholder: StringKey, 110 + pub state: &'state mut TextInputState, 111 + pub disabled: bool, 112 + pub validator: V, 113 + } 114 + 115 + #[must_use] 116 + pub fn show_text_input<V: TextInputValidation, C: Clipboard>( 117 + ctx: &mut FrameCtx<'_>, 118 + input: TextInput<'_, V>, 119 + clipboard: &mut C, 120 + ) -> TextInputResponse<V::Error> { 121 + let TextInput { 122 + id, 123 + rect, 124 + placeholder, 125 + state, 126 + disabled, 127 + validator, 128 + } = input; 129 + clamp_selection_to_text(state); 130 + let interactive = !disabled; 131 + let interaction = ctx.interact( 132 + InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 133 + .focusable(interactive) 134 + .disabled(!interactive), 135 + ); 136 + let live_focused = ctx.is_focused(id); 137 + let mut edits = Vec::new(); 138 + if interactive && live_focused { 139 + edits.extend(drain_edits(ctx, state, clipboard)); 140 + } 141 + let error = validator.validate(&state.text).err(); 142 + let paint = build_paint( 143 + ctx, 144 + PaintInputs { 145 + rect, 146 + placeholder, 147 + state, 148 + disabled, 149 + }, 150 + interaction, 151 + live_focused, 152 + error.is_some(), 153 + ); 154 + state.was_focused = live_focused; 155 + TextInputResponse { 156 + interaction, 157 + edits, 158 + error, 159 + paint, 160 + } 161 + } 162 + 163 + fn drain_edits<C: Clipboard>( 164 + ctx: &mut FrameCtx<'_>, 165 + state: &mut TextInputState, 166 + clipboard: &mut C, 167 + ) -> Vec<TextInputEdit> { 168 + let mut edits = Vec::new(); 169 + let pending = core::mem::take(&mut ctx.input.keys_pressed); 170 + let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| { 171 + let classified = classify(event); 172 + if matches!(classified, ClassifiedKey::PassThrough) { 173 + acc.push(event); 174 + return acc; 175 + } 176 + let before = state.text.clone(); 177 + match perform(classified, state, clipboard) { 178 + Some(action) if always_emit(action) || before != state.text => { 179 + edits.push(TextInputEdit { 180 + action, 181 + before_text: before, 182 + after_text: state.text.clone(), 183 + }); 184 + } 185 + Some(_) => {} 186 + None => acc.push(event), 187 + } 188 + acc 189 + }); 190 + ctx.input.keys_pressed = unconsumed; 191 + 192 + let committed = core::mem::take(&mut ctx.input.text_committed); 193 + if !committed.is_empty() { 194 + let before = state.text.clone(); 195 + insert_text(state, &committed); 196 + edits.push(TextInputEdit { 197 + action: TextInputAction::Insert, 198 + before_text: before, 199 + after_text: state.text.clone(), 200 + }); 201 + } 202 + 203 + edits 204 + } 205 + 206 + fn perform<C: Clipboard>( 207 + classified: ClassifiedKey, 208 + state: &mut TextInputState, 209 + clipboard: &mut C, 210 + ) -> Option<TextInputAction> { 211 + Some(match classified { 212 + ClassifiedKey::Backspace => { 213 + delete_back(state); 214 + TextInputAction::DeleteBack 215 + } 216 + ClassifiedKey::Delete => { 217 + delete_forward(state); 218 + TextInputAction::DeleteForward 219 + } 220 + ClassifiedKey::Move(motion) => { 221 + state.selection = state.selection.apply(&state.text, motion); 222 + TextInputAction::Move 223 + } 224 + ClassifiedKey::SelectAll => { 225 + state.selection = Selection::ranged( 226 + SourceByteIndex::new(0), 227 + SourceByteIndex::new(state.text.len()), 228 + ); 229 + TextInputAction::SelectAll 230 + } 231 + ClassifiedKey::Copy => { 232 + let slice = selected_text(state)?; 233 + clipboard.write(slice); 234 + TextInputAction::Copy 235 + } 236 + ClassifiedKey::Cut => { 237 + let slice = selected_text(state)?; 238 + clipboard.write(slice); 239 + delete_selection(state); 240 + TextInputAction::Cut 241 + } 242 + ClassifiedKey::Paste => { 243 + let text = clipboard.read()?; 244 + insert_text(state, &text); 245 + TextInputAction::Paste 246 + } 247 + ClassifiedKey::PassThrough => unreachable!("filtered above"), 248 + }) 249 + } 250 + 251 + const fn always_emit(action: TextInputAction) -> bool { 252 + matches!( 253 + action, 254 + TextInputAction::Move 255 + | TextInputAction::SelectAll 256 + | TextInputAction::Copy 257 + | TextInputAction::Cut 258 + | TextInputAction::Paste, 259 + ) 260 + } 261 + 262 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 263 + enum ClassifiedKey { 264 + Backspace, 265 + Delete, 266 + Move(SelectionAction), 267 + SelectAll, 268 + Copy, 269 + Cut, 270 + Paste, 271 + PassThrough, 272 + } 273 + 274 + fn classify(event: KeyEvent) -> ClassifiedKey { 275 + let no_alt = !event.modifiers.contains(ModifierMask::ALT); 276 + let no_meta = !event.modifiers.contains(ModifierMask::META); 277 + let ctrl = event.modifiers.contains(ModifierMask::CTRL); 278 + let plain = event.modifiers == ModifierMask::NONE; 279 + if let Some(action) = SelectionAction::from_key(event) { 280 + return ClassifiedKey::Move(action); 281 + } 282 + match event.code { 283 + KeyCode::Named(NamedKey::Backspace) if plain => ClassifiedKey::Backspace, 284 + KeyCode::Named(NamedKey::Delete) if plain => ClassifiedKey::Delete, 285 + KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'a' => ClassifiedKey::SelectAll, 286 + KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'c' => ClassifiedKey::Copy, 287 + KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'x' => ClassifiedKey::Cut, 288 + KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'v' => ClassifiedKey::Paste, 289 + _ => ClassifiedKey::PassThrough, 290 + } 291 + } 292 + 293 + fn insert_text(state: &mut TextInputState, text: &str) { 294 + let min = state.selection.min().value(); 295 + let max = state.selection.max().value(); 296 + state.text.replace_range(min..max, text); 297 + let next = SourceByteIndex::new(min + text.len()); 298 + state.selection = Selection::caret_at(next); 299 + } 300 + 301 + fn delete_back(state: &mut TextInputState) { 302 + if !state.selection.is_empty() { 303 + delete_selection(state); 304 + return; 305 + } 306 + let caret = state.selection.caret().value(); 307 + if caret == 0 { 308 + return; 309 + } 310 + let extended = state.selection.apply( 311 + &state.text, 312 + SelectionAction::Extend(crate::text::CaretMove::PrevGrapheme), 313 + ); 314 + state.selection = extended; 315 + delete_selection(state); 316 + } 317 + 318 + fn delete_forward(state: &mut TextInputState) { 319 + if !state.selection.is_empty() { 320 + delete_selection(state); 321 + return; 322 + } 323 + if state.selection.caret().value() >= state.text.len() { 324 + return; 325 + } 326 + let extended = state.selection.apply( 327 + &state.text, 328 + SelectionAction::Extend(crate::text::CaretMove::NextGrapheme), 329 + ); 330 + state.selection = extended; 331 + delete_selection(state); 332 + } 333 + 334 + fn delete_selection(state: &mut TextInputState) { 335 + let min = state.selection.min().value(); 336 + let max = state.selection.max().value(); 337 + state.text.replace_range(min..max, ""); 338 + state.selection = Selection::caret_at(SourceByteIndex::new(min)); 339 + } 340 + 341 + fn selected_text(state: &TextInputState) -> Option<String> { 342 + if state.selection.is_empty() { 343 + return None; 344 + } 345 + let min = state.selection.min().value(); 346 + let max = state.selection.max().value(); 347 + Some(state.text[min..max].to_owned()) 348 + } 349 + 350 + fn clamp_selection_to_text(state: &mut TextInputState) { 351 + let anchor = clamp_byte_to_boundary(&state.text, state.selection.anchor().value()); 352 + let caret = clamp_byte_to_boundary(&state.text, state.selection.caret().value()); 353 + if anchor != state.selection.anchor().value() || caret != state.selection.caret().value() { 354 + state.selection = 355 + Selection::ranged(SourceByteIndex::new(anchor), SourceByteIndex::new(caret)); 356 + } 357 + } 358 + 359 + fn clamp_byte_to_boundary(text: &str, byte: usize) -> usize { 360 + let bounded = byte.min(text.len()); 361 + if text.is_char_boundary(bounded) { 362 + return bounded; 363 + } 364 + (0..bounded) 365 + .rev() 366 + .find(|&i| text.is_char_boundary(i)) 367 + .unwrap_or(0) 368 + } 369 + 370 + #[derive(Copy, Clone)] 371 + struct PaintInputs<'a> { 372 + rect: LayoutRect, 373 + placeholder: StringKey, 374 + state: &'a TextInputState, 375 + disabled: bool, 376 + } 377 + 378 + fn build_paint( 379 + ctx: &FrameCtx<'_>, 380 + inputs: PaintInputs<'_>, 381 + interaction: Interaction, 382 + live_focused: bool, 383 + has_error: bool, 384 + ) -> Vec<WidgetPaint> { 385 + let PaintInputs { 386 + rect, 387 + placeholder, 388 + state, 389 + disabled, 390 + } = inputs; 391 + let visuals = field_visuals(ctx, disabled, interaction, has_error); 392 + let (label, label_color) = if state.text.is_empty() { 393 + (LabelText::Key(placeholder), visuals.placeholder) 394 + } else { 395 + (LabelText::Owned(state.text.clone()), visuals.text.color) 396 + }; 397 + let mut paint = vec![ 398 + WidgetPaint::Surface { 399 + rect, 400 + fill: visuals.surface.fill, 401 + border: visuals.surface.border, 402 + radius: visuals.surface.radius, 403 + elevation: None, 404 + }, 405 + WidgetPaint::Label { 406 + rect, 407 + text: label, 408 + color: label_color, 409 + role: visuals.text.role, 410 + }, 411 + ]; 412 + if !state.selection.is_empty() { 413 + paint.push(WidgetPaint::SelectionHighlight { 414 + rect, 415 + range: SelectionByteRange::new(state.selection.min(), state.selection.max()), 416 + color: visuals.selection, 417 + }); 418 + } 419 + if live_focused && !disabled { 420 + paint.push(WidgetPaint::Caret { 421 + rect, 422 + byte_offset: state.selection.caret(), 423 + color: visuals.caret, 424 + }); 425 + } 426 + push_focus_ring(ctx, &mut paint, rect, visuals.surface.radius, live_focused); 427 + paint 428 + } 429 + 430 + fn field_visuals( 431 + ctx: &FrameCtx<'_>, 432 + disabled: bool, 433 + interaction: Interaction, 434 + has_error: bool, 435 + ) -> FieldVisuals { 436 + let neutral = ctx.theme.colors.neutral; 437 + let danger = ctx.theme.colors.danger; 438 + let radius = ctx.theme.radius.sm; 439 + let hovered = interaction.hover(); 440 + let focused = interaction.focused(); 441 + let fill = if disabled { 442 + neutral.step(Step12::SUBTLE_BG) 443 + } else { 444 + neutral.step(Step12::APP_BG) 445 + }; 446 + let border_color = if has_error { 447 + danger.step(Step12::SOLID) 448 + } else if focused { 449 + ctx.theme.colors.accent_solid() 450 + } else if hovered { 451 + neutral.step(Step12::HOVER_BORDER) 452 + } else { 453 + neutral.step(Step12::BORDER) 454 + }; 455 + let surface = SurfaceVisuals { 456 + fill, 457 + border: Some(Border { 458 + width: StrokeWidth::HAIRLINE, 459 + color: border_color, 460 + }), 461 + radius, 462 + elevation: None, 463 + }; 464 + let text_color = if disabled { 465 + ctx.theme.colors.text_disabled() 466 + } else { 467 + ctx.theme.colors.text_primary() 468 + }; 469 + FieldVisuals { 470 + surface, 471 + text: TextVisuals { 472 + color: text_color, 473 + role: ctx.theme.typography.body, 474 + }, 475 + placeholder: ctx.theme.colors.text_secondary(), 476 + caret: ctx.theme.colors.text_primary(), 477 + selection: ctx.theme.cad.selection_primary.with_alpha(0.35), 478 + } 479 + } 480 + 481 + #[cfg(test)] 482 + mod tests { 483 + use std::sync::Arc; 484 + 485 + use super::{ 486 + AlwaysValid, Clipboard, MemoryClipboard, TextInput, TextInputAction, TextInputState, 487 + TextInputValidation, show_text_input, 488 + }; 489 + use bone_text::SourceByteIndex; 490 + 491 + use crate::focus::FocusManager; 492 + use crate::frame::FrameCtx; 493 + use crate::hit_test::{HitFrame, HitState}; 494 + use crate::hotkey::HotkeyTable; 495 + use crate::input::{ 496 + FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey, 497 + }; 498 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 499 + use crate::strings::StringKey; 500 + use crate::strings::StringTable; 501 + use crate::text::Selection; 502 + use crate::theme::Theme; 503 + use crate::widget_id::{WidgetId, WidgetKey}; 504 + 505 + const PLACEHOLDER: StringKey = StringKey::new("text.placeholder"); 506 + 507 + fn rect() -> LayoutRect { 508 + LayoutRect::new( 509 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 510 + LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(24.0)), 511 + ) 512 + } 513 + 514 + fn id_widget() -> WidgetId { 515 + WidgetId::ROOT.child(WidgetKey::new("textinput")) 516 + } 517 + 518 + fn focused_with( 519 + state_text: &str, 520 + events: Vec<KeyEvent>, 521 + ) -> (TextInputState, FocusManager, InputSnapshot) { 522 + let state = TextInputState::from_text(state_text); 523 + let mut focus = FocusManager::new(); 524 + focus.register_focusable(id_widget()); 525 + focus.request_focus(id_widget()); 526 + focus.end_frame(); 527 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 528 + input.keys_pressed = events; 529 + (state, focus, input) 530 + } 531 + 532 + fn run<C: Clipboard, V: TextInputValidation>( 533 + state: &mut TextInputState, 534 + focus: &mut FocusManager, 535 + input: &mut InputSnapshot, 536 + clipboard: &mut C, 537 + validator: V, 538 + ) -> super::TextInputResponse<V::Error> { 539 + let theme = Arc::new(Theme::light()); 540 + let table = HotkeyTable::new(); 541 + let mut hits = HitFrame::new(); 542 + let prev = HitState::new(); 543 + let widget = TextInput { 544 + id: id_widget(), 545 + rect: rect(), 546 + placeholder: PLACEHOLDER, 547 + state, 548 + disabled: false, 549 + validator, 550 + }; 551 + let mut ctx = FrameCtx::new( 552 + theme, 553 + input, 554 + focus, 555 + &table, 556 + StringTable::empty(), 557 + &mut hits, 558 + &prev, 559 + ); 560 + show_text_input(&mut ctx, widget, clipboard) 561 + } 562 + 563 + fn ctrl_key(c: char) -> KeyEvent { 564 + KeyEvent::new(KeyCode::Char(KeyChar::from_char(c)), ModifierMask::CTRL) 565 + } 566 + 567 + #[test] 568 + fn typing_inserts_at_caret() { 569 + let (mut state, mut focus, mut input) = focused_with("ab", vec![]); 570 + input.text_committed = "c".to_owned(); 571 + let mut clipboard = MemoryClipboard::default(); 572 + let _ = run( 573 + &mut state, 574 + &mut focus, 575 + &mut input, 576 + &mut clipboard, 577 + AlwaysValid, 578 + ); 579 + assert_eq!(state.text, "abc"); 580 + assert_eq!(state.selection.caret().value(), 3); 581 + } 582 + 583 + #[test] 584 + fn backspace_deletes_grapheme_when_no_selection() { 585 + let (mut state, mut focus, mut input) = focused_with( 586 + "abc", 587 + vec![KeyEvent::new( 588 + KeyCode::Named(NamedKey::Backspace), 589 + ModifierMask::NONE, 590 + )], 591 + ); 592 + let mut clipboard = MemoryClipboard::default(); 593 + let _ = run( 594 + &mut state, 595 + &mut focus, 596 + &mut input, 597 + &mut clipboard, 598 + AlwaysValid, 599 + ); 600 + assert_eq!(state.text, "ab"); 601 + } 602 + 603 + #[test] 604 + fn delete_removes_grapheme_after_caret() { 605 + let (mut state, mut focus, mut input) = focused_with( 606 + "abc", 607 + vec![KeyEvent::new( 608 + KeyCode::Named(NamedKey::Delete), 609 + ModifierMask::NONE, 610 + )], 611 + ); 612 + state.selection = Selection::caret_at(SourceByteIndex::new(1)); 613 + let mut clipboard = MemoryClipboard::default(); 614 + let _ = run( 615 + &mut state, 616 + &mut focus, 617 + &mut input, 618 + &mut clipboard, 619 + AlwaysValid, 620 + ); 621 + assert_eq!(state.text, "ac"); 622 + } 623 + 624 + #[test] 625 + fn cut_copies_selection_and_removes_it() { 626 + let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]); 627 + state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4)); 628 + let mut clipboard = MemoryClipboard::default(); 629 + let response = run( 630 + &mut state, 631 + &mut focus, 632 + &mut input, 633 + &mut clipboard, 634 + AlwaysValid, 635 + ); 636 + assert_eq!(state.text, "ho"); 637 + assert_eq!(clipboard.read(), Some("ell".to_owned())); 638 + assert!( 639 + response 640 + .edits 641 + .iter() 642 + .any(|e| e.action == TextInputAction::Cut) 643 + ); 644 + assert!(input.keys_pressed.is_empty(), "Cut chord drained"); 645 + } 646 + 647 + #[test] 648 + fn copy_passes_through_when_no_selection() { 649 + let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]); 650 + let mut clipboard = MemoryClipboard::default(); 651 + let _ = run( 652 + &mut state, 653 + &mut focus, 654 + &mut input, 655 + &mut clipboard, 656 + AlwaysValid, 657 + ); 658 + assert_eq!( 659 + input.keys_pressed, 660 + vec![ctrl_key('c')], 661 + "Copy with no selection preserves chord for outer handler", 662 + ); 663 + } 664 + 665 + #[test] 666 + fn cut_passes_through_when_no_selection() { 667 + let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]); 668 + let mut clipboard = MemoryClipboard::default(); 669 + let response = run( 670 + &mut state, 671 + &mut focus, 672 + &mut input, 673 + &mut clipboard, 674 + AlwaysValid, 675 + ); 676 + assert_eq!(state.text, "hello"); 677 + assert!(response.edits.is_empty()); 678 + assert_eq!(input.keys_pressed, vec![ctrl_key('x')]); 679 + } 680 + 681 + #[test] 682 + fn paste_passes_through_when_clipboard_empty() { 683 + let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]); 684 + let mut clipboard = MemoryClipboard::default(); 685 + let _ = run( 686 + &mut state, 687 + &mut focus, 688 + &mut input, 689 + &mut clipboard, 690 + AlwaysValid, 691 + ); 692 + assert_eq!(input.keys_pressed, vec![ctrl_key('v')]); 693 + } 694 + 695 + #[test] 696 + fn paste_drains_chord_when_clipboard_has_content() { 697 + let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]); 698 + let mut clipboard = MemoryClipboard::default(); 699 + clipboard.write("x".to_owned()); 700 + let _ = run( 701 + &mut state, 702 + &mut focus, 703 + &mut input, 704 + &mut clipboard, 705 + AlwaysValid, 706 + ); 707 + assert!(input.keys_pressed.is_empty(), "Paste with content drains chord"); 708 + } 709 + 710 + #[test] 711 + fn copy_does_not_modify_text() { 712 + let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]); 713 + state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4)); 714 + let mut clipboard = MemoryClipboard::default(); 715 + let _ = run( 716 + &mut state, 717 + &mut focus, 718 + &mut input, 719 + &mut clipboard, 720 + AlwaysValid, 721 + ); 722 + assert_eq!(state.text, "hello"); 723 + assert_eq!(clipboard.read(), Some("ell".to_owned())); 724 + } 725 + 726 + #[test] 727 + fn paste_inserts_clipboard_text() { 728 + let (mut state, mut focus, mut input) = focused_with("ho", vec![ctrl_key('v')]); 729 + state.selection = Selection::caret_at(SourceByteIndex::new(1)); 730 + let mut clipboard = MemoryClipboard::default(); 731 + clipboard.write("ell".to_owned()); 732 + let _ = run( 733 + &mut state, 734 + &mut focus, 735 + &mut input, 736 + &mut clipboard, 737 + AlwaysValid, 738 + ); 739 + assert_eq!(state.text, "hello"); 740 + assert_eq!(state.selection.caret().value(), 4); 741 + } 742 + 743 + #[test] 744 + fn select_all_then_typing_replaces() { 745 + let (mut state, mut focus, mut input) = focused_with("abc", vec![ctrl_key('a')]); 746 + let mut clipboard = MemoryClipboard::default(); 747 + let _ = run( 748 + &mut state, 749 + &mut focus, 750 + &mut input, 751 + &mut clipboard, 752 + AlwaysValid, 753 + ); 754 + assert_eq!(state.selection.min().value(), 0); 755 + assert_eq!(state.selection.max().value(), 3); 756 + 757 + let (_, _, mut input2) = focused_with("dummy", vec![]); 758 + input2.text_committed = "Z".to_owned(); 759 + let _ = run( 760 + &mut state, 761 + &mut focus, 762 + &mut input2, 763 + &mut clipboard, 764 + AlwaysValid, 765 + ); 766 + assert_eq!(state.text, "Z"); 767 + } 768 + 769 + #[test] 770 + fn arrow_keys_move_caret_without_changing_text() { 771 + let (mut state, mut focus, mut input) = focused_with( 772 + "abc", 773 + vec![KeyEvent::new( 774 + KeyCode::Named(NamedKey::ArrowLeft), 775 + ModifierMask::NONE, 776 + )], 777 + ); 778 + let mut clipboard = MemoryClipboard::default(); 779 + let _ = run( 780 + &mut state, 781 + &mut focus, 782 + &mut input, 783 + &mut clipboard, 784 + AlwaysValid, 785 + ); 786 + assert_eq!(state.text, "abc"); 787 + assert_eq!(state.selection.caret().value(), 2); 788 + } 789 + 790 + #[test] 791 + fn validator_surfaces_typed_error() { 792 + struct LimitFour; 793 + #[derive(Clone, Debug, PartialEq, Eq)] 794 + enum Err { 795 + TooLong, 796 + } 797 + impl TextInputValidation for LimitFour { 798 + type Error = Err; 799 + fn validate(&self, text: &str) -> Result<(), Err> { 800 + if text.len() > 4 { 801 + Err(Err::TooLong) 802 + } else { 803 + Ok(()) 804 + } 805 + } 806 + } 807 + let (mut state, mut focus, mut input) = focused_with("abcde", vec![]); 808 + let mut clipboard = MemoryClipboard::default(); 809 + let response = run( 810 + &mut state, 811 + &mut focus, 812 + &mut input, 813 + &mut clipboard, 814 + LimitFour, 815 + ); 816 + assert_eq!(response.error, Some(Err::TooLong)); 817 + } 818 + 819 + #[test] 820 + fn unfocused_input_does_not_consume_keys() { 821 + let mut state = TextInputState::from_text("ab"); 822 + let mut focus = FocusManager::new(); 823 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 824 + let event = KeyEvent::new(KeyCode::Char(KeyChar::from_char('c')), ModifierMask::NONE); 825 + input.keys_pressed.push(event); 826 + let mut clipboard = MemoryClipboard::default(); 827 + let _ = run( 828 + &mut state, 829 + &mut focus, 830 + &mut input, 831 + &mut clipboard, 832 + AlwaysValid, 833 + ); 834 + assert_eq!(state.text, "ab"); 835 + assert_eq!(input.keys_pressed, vec![event]); 836 + } 837 + 838 + #[test] 839 + fn ime_committed_text_inserts_into_buffer() { 840 + let (mut state, mut focus, mut input) = focused_with("é", vec![]); 841 + state.selection = Selection::caret_at(SourceByteIndex::new("é".len())); 842 + input.text_committed = "あ".to_owned(); 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, "éあ"); 852 + } 853 + 854 + #[test] 855 + fn printable_keystroke_does_not_insert_via_keys_pressed() { 856 + let (mut state, mut focus, mut input) = focused_with( 857 + "", 858 + vec![KeyEvent::new( 859 + KeyCode::Char(KeyChar::from_char('a')), 860 + ModifierMask::NONE, 861 + )], 862 + ); 863 + let mut clipboard = MemoryClipboard::default(); 864 + let _ = run( 865 + &mut state, 866 + &mut focus, 867 + &mut input, 868 + &mut clipboard, 869 + AlwaysValid, 870 + ); 871 + assert_eq!( 872 + state.text, "", 873 + "printable input is the platform's text_committed responsibility, not keys_pressed", 874 + ); 875 + } 876 + 877 + #[test] 878 + fn paint_carries_user_text_as_owned_label() { 879 + let (mut state, mut focus, mut input) = focused_with("hello", vec![]); 880 + let mut clipboard = MemoryClipboard::default(); 881 + let response = run( 882 + &mut state, 883 + &mut focus, 884 + &mut input, 885 + &mut clipboard, 886 + AlwaysValid, 887 + ); 888 + let owned_text = response.paint.iter().find_map(|p| match p { 889 + super::WidgetPaint::Label { 890 + text: super::LabelText::Owned(s), 891 + .. 892 + } => Some(s.clone()), 893 + _ => None, 894 + }); 895 + assert_eq!(owned_text.as_deref(), Some("hello")); 896 + } 897 + 898 + #[test] 899 + fn empty_input_paints_placeholder_key() { 900 + let (mut state, mut focus, mut input) = focused_with("", vec![]); 901 + let mut clipboard = MemoryClipboard::default(); 902 + let response = run( 903 + &mut state, 904 + &mut focus, 905 + &mut input, 906 + &mut clipboard, 907 + AlwaysValid, 908 + ); 909 + let key = response.paint.iter().find_map(|p| match p { 910 + super::WidgetPaint::Label { 911 + text: super::LabelText::Key(k), 912 + .. 913 + } => Some(*k), 914 + _ => None, 915 + }); 916 + assert_eq!(key, Some(PLACEHOLDER)); 917 + } 918 + 919 + #[test] 920 + fn caret_paint_carries_byte_offset() { 921 + let (mut state, mut focus, mut input) = focused_with("abc", vec![]); 922 + state.selection = Selection::caret_at(SourceByteIndex::new(2)); 923 + let mut clipboard = MemoryClipboard::default(); 924 + let response = run( 925 + &mut state, 926 + &mut focus, 927 + &mut input, 928 + &mut clipboard, 929 + AlwaysValid, 930 + ); 931 + let offset = response.paint.iter().find_map(|p| match p { 932 + super::WidgetPaint::Caret { byte_offset, .. } => Some(*byte_offset), 933 + _ => None, 934 + }); 935 + assert_eq!(offset, Some(SourceByteIndex::new(2))); 936 + } 937 + 938 + #[test] 939 + fn selection_paint_carries_byte_range() { 940 + let (mut state, mut focus, mut input) = focused_with("hello", vec![]); 941 + state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4)); 942 + let mut clipboard = MemoryClipboard::default(); 943 + let response = run( 944 + &mut state, 945 + &mut focus, 946 + &mut input, 947 + &mut clipboard, 948 + AlwaysValid, 949 + ); 950 + let range = response.paint.iter().find_map(|p| match p { 951 + super::WidgetPaint::SelectionHighlight { range, .. } => Some(*range), 952 + _ => None, 953 + }); 954 + let expected = 955 + super::SelectionByteRange::new(SourceByteIndex::new(1), SourceByteIndex::new(4)); 956 + assert_eq!(range, Some(expected)); 957 + } 958 + 959 + #[test] 960 + fn out_of_bounds_selection_snaps_to_text_length_before_editing() { 961 + let (mut state, mut focus, mut input) = focused_with("ab", vec![]); 962 + state.selection = Selection::caret_at(SourceByteIndex::new(99)); 963 + input.text_committed = "c".to_owned(); 964 + let mut clipboard = MemoryClipboard::default(); 965 + let _ = run( 966 + &mut state, 967 + &mut focus, 968 + &mut input, 969 + &mut clipboard, 970 + AlwaysValid, 971 + ); 972 + assert_eq!(state.text, "abc"); 973 + assert_eq!(state.selection.caret().value(), 3); 974 + } 975 + 976 + #[test] 977 + fn mid_codepoint_selection_snaps_to_grapheme_boundary_before_editing() { 978 + let (mut state, mut focus, mut input) = focused_with("é", vec![]); 979 + state.selection = Selection::caret_at(SourceByteIndex::new(1)); 980 + input.text_committed = "x".to_owned(); 981 + let mut clipboard = MemoryClipboard::default(); 982 + let _ = run( 983 + &mut state, 984 + &mut focus, 985 + &mut input, 986 + &mut clipboard, 987 + AlwaysValid, 988 + ); 989 + assert_eq!(state.text, "xé"); 990 + assert_eq!(state.selection.caret().value(), "x".len()); 991 + } 992 + 993 + #[test] 994 + fn delete_back_with_invalid_selection_does_not_panic() { 995 + let (mut state, mut focus, mut input) = focused_with( 996 + "abc", 997 + vec![KeyEvent::new( 998 + KeyCode::Named(NamedKey::Backspace), 999 + ModifierMask::NONE, 1000 + )], 1001 + ); 1002 + state.selection = Selection::ranged(SourceByteIndex::new(2), SourceByteIndex::new(99)); 1003 + let mut clipboard = MemoryClipboard::default(); 1004 + let _ = run( 1005 + &mut state, 1006 + &mut focus, 1007 + &mut input, 1008 + &mut clipboard, 1009 + AlwaysValid, 1010 + ); 1011 + assert_eq!(state.text, "ab"); 1012 + } 1013 + }