Another project
0

Configure Feed

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

at main 49 kB View raw
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, ""); 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}