we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

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

Implement text editing: cursor, selection, and keyboard input (Phase 16)

Add full text editing support for <input> and <textarea> elements:

- InputState module in DOM crate tracks cursor position, selection range,
and edited text buffer per form control
- Platform crate: KeyModifiers struct with Cmd/Option/Ctrl/Shift flags,
mouse-down/drag handlers, NSPasteboard clipboard read/write
- Keyboard input: character insertion, Backspace/Delete, Return for textarea
- Cursor movement: arrow keys, Home/End, Cmd+arrows (line start/end),
Option+arrows (word navigation), Cmd+Up/Down (document start/end)
- Text selection: Shift+arrows extend selection, Cmd+A select all,
click to position cursor, double-click to select word, click-drag to
extend selection
- Clipboard: Cmd+C copy, Cmd+X cut, Cmd+V paste via macOS pasteboard
- Rendering: cursor caret and selection highlight in focused text inputs
- Value sync: edited text synced back to DOM value attribute
- 25+ unit tests for cursor movement, selection, editing, word/line
navigation, Unicode handling, and change detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1622 -51
+509 -15
crates/browser/src/main.rs
··· 302 302 }); 303 303 } 304 304 305 - /// macOS key code for the Tab key. 305 + // --------------------------------------------------------------------------- 306 + // macOS key codes 307 + // --------------------------------------------------------------------------- 308 + 306 309 const KEY_CODE_TAB: u16 = 48; 310 + const KEY_CODE_DELETE: u16 = 51; // Backspace 311 + const KEY_CODE_FORWARD_DELETE: u16 = 117; 312 + const KEY_CODE_LEFT: u16 = 123; 313 + const KEY_CODE_RIGHT: u16 = 124; 314 + const KEY_CODE_UP: u16 = 126; 315 + const KEY_CODE_DOWN: u16 = 125; 316 + const KEY_CODE_HOME: u16 = 115; 317 + const KEY_CODE_END: u16 = 119; 318 + const KEY_CODE_RETURN: u16 = 36; 319 + const KEY_CODE_A: u16 = 0; 320 + const KEY_CODE_C: u16 = 8; 321 + const KEY_CODE_V: u16 = 9; 322 + const KEY_CODE_X: u16 = 7; 323 + 324 + /// Returns true if the given element is an editable text control. 325 + fn is_text_editable(doc: &we_dom::Document, node: NodeId) -> bool { 326 + match doc.tag_name(node) { 327 + Some("textarea") => true, 328 + Some("input") => { 329 + let t = doc.get_attribute(node, "type").unwrap_or("text"); 330 + matches!( 331 + t, 332 + "text" | "password" | "email" | "url" | "search" | "tel" | "number" 333 + ) 334 + } 335 + _ => false, 336 + } 337 + } 338 + 339 + /// Ensure an InputState exists for `node`, initializing from the DOM value attribute. 340 + fn ensure_input_state(doc: &mut we_dom::Document, node: NodeId) { 341 + let default_value = match doc.tag_name(node) { 342 + Some("textarea") => { 343 + // For textarea, the initial value is the text content. 344 + let mut text = String::new(); 345 + collect_text_content_into(doc, node, &mut text); 346 + text 347 + } 348 + _ => doc.get_attribute(node, "value").unwrap_or("").to_string(), 349 + }; 350 + doc.input_states.get_or_create(node, &default_value); 351 + } 352 + 353 + /// Collect text content of a node (for textarea initial value). 354 + fn collect_text_content_into(doc: &we_dom::Document, node: NodeId, out: &mut String) { 355 + match doc.node_data(node) { 356 + we_dom::NodeData::Text { data } => out.push_str(data), 357 + _ => { 358 + for child in doc.children(node) { 359 + collect_text_content_into(doc, child, out); 360 + } 361 + } 362 + } 363 + } 364 + 365 + /// Re-render the page and mark the view as needing display. 366 + fn rerender(state: &mut BrowserState) { 367 + let viewport_width = state.bitmap.width() as f32; 368 + let viewport_height = state.bitmap.height() as f32; 369 + let content_height = render_page( 370 + &state.page, 371 + &state.font, 372 + &mut state.backend, 373 + &state.view, 374 + &mut state.bitmap, 375 + viewport_width, 376 + viewport_height, 377 + state.page_scroll_y, 378 + &state.scroll_offsets, 379 + ); 380 + state.content_height = content_height; 381 + if let ViewKind::Bitmap(bitmap_view) = &state.view { 382 + bitmap_view.set_needs_display(); 383 + } 384 + } 307 385 308 386 /// Called by the platform crate on key-down events. 309 - fn handle_key_down(key_code: u16, _chars: &str, shift: bool) { 387 + fn handle_key_down(key_code: u16, chars: &str, mods: appkit::KeyModifiers) { 310 388 if key_code == KEY_CODE_TAB { 311 - handle_tab(shift); 389 + handle_tab(mods.shift); 390 + return; 312 391 } 392 + 393 + STATE.with(|state| { 394 + let mut state = state.borrow_mut(); 395 + let state = match state.as_mut() { 396 + Some(s) => s, 397 + None => return, 398 + }; 399 + 400 + // Only process editing keys when a text control is focused. 401 + let focused = match state.page.doc.active_element() { 402 + Some(n) => n, 403 + None => return, 404 + }; 405 + if !is_text_editable(&state.page.doc, focused) { 406 + return; 407 + } 408 + 409 + // Ensure InputState is initialized for this element. 410 + ensure_input_state(&mut state.page.doc, focused); 411 + 412 + let mut needs_render = false; 413 + let mut text_changed = false; 414 + 415 + // Cmd+A: select all 416 + if mods.command && key_code == KEY_CODE_A { 417 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 418 + is.select_all(); 419 + needs_render = true; 420 + } 421 + } 422 + // Cmd+C: copy 423 + else if mods.command && key_code == KEY_CODE_C { 424 + if let Some(is) = state.page.doc.input_states.get(focused) { 425 + if is.has_selection() { 426 + appkit::clipboard_set_string(is.selected_text()); 427 + } 428 + } 429 + } 430 + // Cmd+X: cut 431 + else if mods.command && key_code == KEY_CODE_X { 432 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 433 + if is.has_selection() { 434 + appkit::clipboard_set_string(is.selected_text()); 435 + is.insert(""); 436 + text_changed = true; 437 + needs_render = true; 438 + } 439 + } 440 + } 441 + // Cmd+V: paste 442 + else if mods.command && key_code == KEY_CODE_V { 443 + if let Some(clipboard) = appkit::clipboard_get_string() { 444 + let is_textarea = state.page.doc.tag_name(focused) == Some("textarea"); 445 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 446 + // For single-line inputs, strip newlines. 447 + let paste_text = if !is_textarea { 448 + clipboard.replace(['\n', '\r'], "") 449 + } else { 450 + clipboard 451 + }; 452 + if is.insert(&paste_text) { 453 + text_changed = true; 454 + needs_render = true; 455 + } 456 + } 457 + } 458 + } 459 + // Arrow keys 460 + else if key_code == KEY_CODE_LEFT { 461 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 462 + if mods.command { 463 + is.move_to_line_start(mods.shift); 464 + } else if mods.option { 465 + is.move_word_left(mods.shift); 466 + } else { 467 + is.move_left(mods.shift); 468 + } 469 + needs_render = true; 470 + } 471 + } else if key_code == KEY_CODE_RIGHT { 472 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 473 + if mods.command { 474 + is.move_to_line_end(mods.shift); 475 + } else if mods.option { 476 + is.move_word_right(mods.shift); 477 + } else { 478 + is.move_right(mods.shift); 479 + } 480 + needs_render = true; 481 + } 482 + } else if key_code == KEY_CODE_UP { 483 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 484 + if mods.command { 485 + is.move_to_start(mods.shift); 486 + } else { 487 + // For single-line inputs, move to start. 488 + is.move_to_line_start(mods.shift); 489 + } 490 + needs_render = true; 491 + } 492 + } else if key_code == KEY_CODE_DOWN { 493 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 494 + if mods.command { 495 + is.move_to_end(mods.shift); 496 + } else { 497 + // For single-line inputs, move to end. 498 + is.move_to_line_end(mods.shift); 499 + } 500 + needs_render = true; 501 + } 502 + } 503 + // Home / End 504 + else if key_code == KEY_CODE_HOME { 505 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 506 + is.move_to_start(mods.shift); 507 + needs_render = true; 508 + } 509 + } else if key_code == KEY_CODE_END { 510 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 511 + is.move_to_end(mods.shift); 512 + needs_render = true; 513 + } 514 + } 515 + // Backspace / Delete 516 + else if key_code == KEY_CODE_DELETE { 517 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 518 + let changed = if mods.command { 519 + is.delete_to_line_start() 520 + } else if mods.option { 521 + is.delete_word_backward() 522 + } else { 523 + is.delete_backward() 524 + }; 525 + if changed { 526 + text_changed = true; 527 + needs_render = true; 528 + } 529 + } 530 + } else if key_code == KEY_CODE_FORWARD_DELETE { 531 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 532 + if is.delete_forward() { 533 + text_changed = true; 534 + needs_render = true; 535 + } 536 + } 537 + } 538 + // Return/Enter 539 + else if key_code == KEY_CODE_RETURN { 540 + if state.page.doc.tag_name(focused) == Some("textarea") { 541 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 542 + if is.insert("\n") { 543 + text_changed = true; 544 + needs_render = true; 545 + } 546 + } 547 + } 548 + // For single-line inputs, Enter doesn't insert text. 549 + } 550 + // Character input (printable characters, no Cmd modifier). 551 + else if !mods.command && !mods.control { 552 + // Filter out non-printable control characters. 553 + let printable: String = chars 554 + .chars() 555 + .filter(|c| !c.is_control() || *c == '\t') 556 + .collect(); 557 + if !printable.is_empty() { 558 + // For single-line inputs, strip newlines. 559 + let is_textarea = state.page.doc.tag_name(focused) == Some("textarea"); 560 + let insert_text = if !is_textarea { 561 + printable.replace(['\n', '\r'], "") 562 + } else { 563 + printable 564 + }; 565 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 566 + if is.insert(&insert_text) { 567 + text_changed = true; 568 + needs_render = true; 569 + } 570 + } 571 + } 572 + } 573 + 574 + // Sync edited value back to the DOM `value` attribute. 575 + if text_changed { 576 + if let Some(is) = state.page.doc.input_states.get(focused) { 577 + let new_value = is.text().to_string(); 578 + state.page.doc.set_attribute(focused, "value", &new_value); 579 + } 580 + } 581 + 582 + if needs_render { 583 + rerender(state); 584 + } 585 + }); 313 586 } 314 587 315 588 /// Advance (or reverse) focus through the document's tab order. ··· 326 599 return; 327 600 } 328 601 602 + let old_focus = state.page.doc.active_element(); 603 + 329 604 let current = state.page.doc.active_element(); 330 605 let next = match current { 331 606 Some(cur) => { ··· 359 634 } 360 635 }; 361 636 637 + // Initialize input state for the newly focused element. 638 + if is_text_editable(&state.page.doc, next) { 639 + ensure_input_state(&mut state.page.doc, next); 640 + // Select all text on Tab focus. 641 + if let Some(is) = state.page.doc.input_states.get_mut(next) { 642 + is.select_all(); 643 + is.record_focus_value(); 644 + } 645 + } 646 + 647 + // Record focus change (for old element's `change` event). 648 + let _ = old_focus; 649 + 362 650 state.page.doc.set_active_element(Some(next), true); 651 + rerender(state); 652 + }); 653 + } 363 654 364 - // Re-render to show focus ring. 655 + /// Handle mouse-down events — focus text inputs and position cursor. 656 + fn handle_mouse_down(x: f64, y: f64, click_count: u32, _mods: appkit::KeyModifiers) { 657 + STATE.with(|state| { 658 + let mut state = state.borrow_mut(); 659 + let state = match state.as_mut() { 660 + Some(s) => s, 661 + None => return, 662 + }; 663 + 664 + let view_x = x as f32; 665 + let view_y = y as f32 + state.page_scroll_y; 666 + 667 + // Hit-test: find the form control element at (view_x, view_y). 365 668 let viewport_width = state.bitmap.width() as f32; 366 669 let viewport_height = state.bitmap.height() as f32; 367 - let content_height = render_page( 368 - &state.page, 670 + 671 + // We need to do layout to find which element was clicked. 672 + // Re-use the last layout by doing a hit-test on the layout tree. 673 + let styled = match we_style::computed::resolve_styles( 674 + &state.page.doc, 675 + std::slice::from_ref(&state.page.stylesheet), 676 + (viewport_width, viewport_height), 677 + ) { 678 + Some(s) => s, 679 + None => return, 680 + }; 681 + 682 + let mut img_sizes = image_sizes(&state.page.images); 683 + let svg_sizes = collect_svg_sizes(&state.page.doc); 684 + img_sizes.extend(svg_sizes); 685 + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); 686 + 687 + let tree = we_layout::layout( 688 + &styled, 689 + &state.page.doc, 690 + viewport_width, 691 + viewport_height, 369 692 &state.font, 370 - &mut state.backend, 371 - &state.view, 372 - &mut state.bitmap, 693 + &img_sizes, 694 + ); 695 + 696 + // Hit-test the layout tree. 697 + if let Some((node, local_x, _content_width, font_size)) = 698 + hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) 699 + { 700 + // Focus the clicked element. 701 + let was_focused = state.page.doc.active_element() == Some(node); 702 + state.page.doc.set_active_element(Some(node), false); 703 + 704 + if is_text_editable(&state.page.doc, node) { 705 + ensure_input_state(&mut state.page.doc, node); 706 + 707 + if !was_focused { 708 + // First click to focus — select all and record focus value. 709 + if let Some(is) = state.page.doc.input_states.get_mut(node) { 710 + is.select_all(); 711 + is.record_focus_value(); 712 + } 713 + } else if click_count >= 2 { 714 + // Double-click: select word at cursor position. 715 + let char_width = font_size * 0.6; 716 + let char_idx = if char_width > 0.0 { 717 + (local_x / char_width) as usize 718 + } else { 719 + 0 720 + }; 721 + if let Some(is) = state.page.doc.input_states.get_mut(node) { 722 + let byte_pos = char_to_byte_offset(is.text(), char_idx); 723 + is.select_word_at(byte_pos); 724 + } 725 + } else { 726 + // Single click: position cursor. 727 + let char_width = font_size * 0.6; 728 + let char_idx = if char_width > 0.0 { 729 + (local_x / char_width).round() as usize 730 + } else { 731 + 0 732 + }; 733 + if let Some(is) = state.page.doc.input_states.get_mut(node) { 734 + let byte_pos = char_to_byte_offset(is.text(), char_idx); 735 + is.set_cursor(byte_pos); 736 + } 737 + } 738 + } 739 + rerender(state); 740 + } else { 741 + // Clicked outside any form control — blur. 742 + if state.page.doc.active_element().is_some() { 743 + state.page.doc.set_active_element(None, false); 744 + rerender(state); 745 + } 746 + } 747 + }); 748 + } 749 + 750 + /// Handle mouse-dragged events — extend text selection. 751 + fn handle_mouse_dragged(x: f64, y: f64) { 752 + STATE.with(|state| { 753 + let mut state = state.borrow_mut(); 754 + let state = match state.as_mut() { 755 + Some(s) => s, 756 + None => return, 757 + }; 758 + 759 + let focused = match state.page.doc.active_element() { 760 + Some(n) if is_text_editable(&state.page.doc, n) => n, 761 + _ => return, 762 + }; 763 + 764 + let view_x = x as f32; 765 + let view_y = y as f32 + state.page_scroll_y; 766 + let viewport_width = state.bitmap.width() as f32; 767 + let viewport_height = state.bitmap.height() as f32; 768 + 769 + let styled = match we_style::computed::resolve_styles( 770 + &state.page.doc, 771 + std::slice::from_ref(&state.page.stylesheet), 772 + (viewport_width, viewport_height), 773 + ) { 774 + Some(s) => s, 775 + None => return, 776 + }; 777 + 778 + let mut img_sizes = image_sizes(&state.page.images); 779 + let svg_sizes = collect_svg_sizes(&state.page.doc); 780 + img_sizes.extend(svg_sizes); 781 + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); 782 + 783 + let tree = we_layout::layout( 784 + &styled, 785 + &state.page.doc, 373 786 viewport_width, 374 787 viewport_height, 375 - state.page_scroll_y, 376 - &state.scroll_offsets, 788 + &state.font, 789 + &img_sizes, 377 790 ); 378 - state.content_height = content_height; 379 791 380 - if let ViewKind::Bitmap(bitmap_view) = &state.view { 381 - bitmap_view.set_needs_display(); 792 + if let Some((_, local_x, _, font_size)) = 793 + hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) 794 + { 795 + let char_width = font_size * 0.6; 796 + let char_idx = if char_width > 0.0 { 797 + (local_x / char_width).round() as usize 798 + } else { 799 + 0 800 + }; 801 + if let Some(is) = state.page.doc.input_states.get_mut(focused) { 802 + let byte_pos = char_to_byte_offset(is.text(), char_idx); 803 + is.extend_selection(byte_pos); 804 + } 805 + rerender(state); 382 806 } 383 807 }); 808 + } 809 + 810 + /// Convert a character index to a byte offset in a string. 811 + fn char_to_byte_offset(s: &str, char_idx: usize) -> usize { 812 + s.char_indices() 813 + .nth(char_idx) 814 + .map(|(i, _)| i) 815 + .unwrap_or(s.len()) 816 + } 817 + 818 + /// Hit-test the layout tree for a form control at the given coordinates. 819 + /// 820 + /// Returns `(NodeId, local_x_in_content, content_width, font_size)` if a 821 + /// text-editable form control was hit. 822 + fn hit_test_form_control( 823 + layout_box: &we_layout::LayoutBox, 824 + x: f32, 825 + y: f32, 826 + parent_x: f32, 827 + parent_y: f32, 828 + ) -> Option<(NodeId, f32, f32, f32)> { 829 + let bx = parent_x + layout_box.rect.x - layout_box.padding.left - layout_box.border.left; 830 + let by = parent_y + layout_box.rect.y - layout_box.padding.top - layout_box.border.top; 831 + let bw = layout_box.rect.width 832 + + layout_box.padding.left 833 + + layout_box.padding.right 834 + + layout_box.border.left 835 + + layout_box.border.right; 836 + let bh = layout_box.rect.height 837 + + layout_box.padding.top 838 + + layout_box.padding.bottom 839 + + layout_box.border.top 840 + + layout_box.border.bottom; 841 + 842 + // Check if point is within this box. 843 + if x >= bx && x < bx + bw && y >= by && y < by + bh { 844 + // Check children first (front-to-back). 845 + for child in layout_box.children.iter().rev() { 846 + if let Some(hit) = hit_test_form_control( 847 + child, 848 + x, 849 + y, 850 + parent_x + layout_box.rect.x, 851 + parent_y + layout_box.rect.y, 852 + ) { 853 + return Some(hit); 854 + } 855 + } 856 + 857 + // If this is a form control, return it. 858 + if let Some(ref fc) = layout_box.form_control { 859 + if matches!( 860 + fc.control_type, 861 + we_layout::FormControlType::TextInput 862 + | we_layout::FormControlType::Password 863 + | we_layout::FormControlType::Textarea 864 + ) { 865 + if let we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) = 866 + layout_box.box_type 867 + { 868 + let content_x = parent_x + layout_box.rect.x; 869 + let local_x = (x - content_x).max(0.0); 870 + return Some((node, local_x, layout_box.rect.width, layout_box.font_size)); 871 + } 872 + } 873 + } 874 + } 875 + None 384 876 } 385 877 386 878 /// Called by the platform crate on scroll wheel events. ··· 699 1191 }); 700 1192 }); 701 1193 702 - // Register resize, scroll, and key handlers. 1194 + // Register resize, scroll, key, and mouse handlers. 703 1195 appkit::set_resize_handler(handle_resize); 704 1196 appkit::set_scroll_handler(handle_scroll); 705 1197 appkit::set_key_handler(handle_key_down); 1198 + appkit::set_mouse_down_handler(handle_mouse_down); 1199 + appkit::set_mouse_dragged_handler(handle_mouse_dragged); 706 1200 707 1201 window.make_key_and_order_front(); 708 1202 app.activate();
+775
crates/dom/src/input_state.rs
··· 1 + //! Editable text state for form controls (text inputs, textareas). 2 + //! 3 + //! Tracks cursor position, selection range, and the edited text buffer 4 + //! independently from the DOM `value` attribute. The `value` attribute 5 + //! is the *default* value; this module tracks the *current* value as 6 + //! modified by user interaction. 7 + 8 + use std::collections::HashMap; 9 + 10 + use crate::NodeId; 11 + 12 + /// Selection direction for tracking how the selection was created. 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 + pub enum SelectionDirection { 15 + /// Selection was created by moving forward (left to right). 16 + Forward, 17 + /// Selection was created by moving backward (right to left). 18 + Backward, 19 + /// No directional bias (e.g., select-all). 20 + None, 21 + } 22 + 23 + /// The editing state for a single text input or textarea. 24 + #[derive(Debug, Clone)] 25 + pub struct InputState { 26 + /// The current text content (may differ from the DOM attribute). 27 + text: String, 28 + /// Cursor position as a byte offset into `text`. 29 + /// When there is no selection, this is the caret position. 30 + /// When there is a selection, this is the "active" end. 31 + cursor: usize, 32 + /// Selection anchor (the "fixed" end of the selection). 33 + /// When equal to `cursor`, there is no selection. 34 + anchor: usize, 35 + /// Direction the selection was created in. 36 + pub direction: SelectionDirection, 37 + /// The value at the time the element last received focus, 38 + /// used to determine whether to fire a `change` event on blur. 39 + value_on_focus: String, 40 + /// Whether the text has been modified since last sync to DOM. 41 + dirty: bool, 42 + } 43 + 44 + impl InputState { 45 + /// Create a new input state with the given initial text. 46 + pub fn new(text: &str) -> Self { 47 + let len = text.len(); 48 + InputState { 49 + text: text.to_string(), 50 + cursor: len, 51 + anchor: len, 52 + direction: SelectionDirection::None, 53 + value_on_focus: text.to_string(), 54 + dirty: false, 55 + } 56 + } 57 + 58 + /// The current text value. 59 + pub fn text(&self) -> &str { 60 + &self.text 61 + } 62 + 63 + /// The cursor (caret) byte position. 64 + pub fn cursor(&self) -> usize { 65 + self.cursor 66 + } 67 + 68 + /// The anchor byte position (start of selection or same as cursor if no selection). 69 + pub fn anchor(&self) -> usize { 70 + self.anchor 71 + } 72 + 73 + /// Whether there is a text selection (cursor != anchor). 74 + pub fn has_selection(&self) -> bool { 75 + self.cursor != self.anchor 76 + } 77 + 78 + /// Returns (start, end) of the selection range in byte offsets. 79 + /// `start <= end` always holds. 80 + pub fn selection_range(&self) -> (usize, usize) { 81 + if self.cursor <= self.anchor { 82 + (self.cursor, self.anchor) 83 + } else { 84 + (self.anchor, self.cursor) 85 + } 86 + } 87 + 88 + /// Move cursor to a position, collapsing the selection. 89 + pub fn set_cursor(&mut self, pos: usize) { 90 + let pos = pos.min(self.text.len()); 91 + // Snap to char boundary. 92 + let pos = snap_to_char_boundary(&self.text, pos); 93 + self.cursor = pos; 94 + self.anchor = pos; 95 + self.direction = SelectionDirection::None; 96 + } 97 + 98 + /// Move cursor to a position, extending the selection from the anchor. 99 + pub fn extend_selection(&mut self, pos: usize) { 100 + let pos = pos.min(self.text.len()); 101 + let pos = snap_to_char_boundary(&self.text, pos); 102 + self.cursor = pos; 103 + self.direction = if self.cursor >= self.anchor { 104 + SelectionDirection::Forward 105 + } else { 106 + SelectionDirection::Backward 107 + }; 108 + } 109 + 110 + /// Select the entire text. 111 + pub fn select_all(&mut self) { 112 + self.anchor = 0; 113 + self.cursor = self.text.len(); 114 + self.direction = SelectionDirection::Forward; 115 + } 116 + 117 + /// Insert text at the cursor position (or replace the selection). 118 + /// Returns true if the text changed. 119 + pub fn insert(&mut self, s: &str) -> bool { 120 + if s.is_empty() && !self.has_selection() { 121 + return false; 122 + } 123 + let (start, end) = self.selection_range(); 124 + self.text.replace_range(start..end, s); 125 + let new_pos = start + s.len(); 126 + self.cursor = new_pos; 127 + self.anchor = new_pos; 128 + self.direction = SelectionDirection::None; 129 + self.dirty = true; 130 + true 131 + } 132 + 133 + /// Delete the character before the cursor (Backspace). 134 + /// If there is a selection, delete the selection instead. 135 + /// Returns true if the text changed. 136 + pub fn delete_backward(&mut self) -> bool { 137 + if self.has_selection() { 138 + return self.insert(""); 139 + } 140 + if self.cursor == 0 { 141 + return false; 142 + } 143 + let prev = prev_char_boundary(&self.text, self.cursor); 144 + self.text.replace_range(prev..self.cursor, ""); 145 + self.cursor = prev; 146 + self.anchor = prev; 147 + self.dirty = true; 148 + true 149 + } 150 + 151 + /// Delete the character after the cursor (Forward Delete). 152 + /// If there is a selection, delete the selection instead. 153 + /// Returns true if the text changed. 154 + pub fn delete_forward(&mut self) -> bool { 155 + if self.has_selection() { 156 + return self.insert(""); 157 + } 158 + if self.cursor >= self.text.len() { 159 + return false; 160 + } 161 + let next = next_char_boundary(&self.text, self.cursor); 162 + self.text.replace_range(self.cursor..next, ""); 163 + self.dirty = true; 164 + true 165 + } 166 + 167 + /// Delete the word before the cursor (Option+Backspace on macOS). 168 + /// Returns true if the text changed. 169 + pub fn delete_word_backward(&mut self) -> bool { 170 + if self.has_selection() { 171 + return self.insert(""); 172 + } 173 + if self.cursor == 0 { 174 + return false; 175 + } 176 + let word_start = prev_word_boundary(&self.text, self.cursor); 177 + self.text.replace_range(word_start..self.cursor, ""); 178 + self.cursor = word_start; 179 + self.anchor = word_start; 180 + self.dirty = true; 181 + true 182 + } 183 + 184 + /// Delete from cursor to the beginning of the line (Cmd+Backspace on macOS). 185 + /// Returns true if the text changed. 186 + pub fn delete_to_line_start(&mut self) -> bool { 187 + if self.has_selection() { 188 + return self.insert(""); 189 + } 190 + if self.cursor == 0 { 191 + return false; 192 + } 193 + let line_start = line_start_offset(&self.text, self.cursor); 194 + self.text.replace_range(line_start..self.cursor, ""); 195 + self.cursor = line_start; 196 + self.anchor = line_start; 197 + self.dirty = true; 198 + true 199 + } 200 + 201 + /// Move cursor one character to the left. 202 + pub fn move_left(&mut self, extend: bool) { 203 + if !extend && self.has_selection() { 204 + let (start, _) = self.selection_range(); 205 + self.set_cursor(start); 206 + return; 207 + } 208 + if self.cursor > 0 { 209 + let new_pos = prev_char_boundary(&self.text, self.cursor); 210 + if extend { 211 + self.extend_selection(new_pos); 212 + } else { 213 + self.set_cursor(new_pos); 214 + } 215 + } 216 + } 217 + 218 + /// Move cursor one character to the right. 219 + pub fn move_right(&mut self, extend: bool) { 220 + if !extend && self.has_selection() { 221 + let (_, end) = self.selection_range(); 222 + self.set_cursor(end); 223 + return; 224 + } 225 + if self.cursor < self.text.len() { 226 + let new_pos = next_char_boundary(&self.text, self.cursor); 227 + if extend { 228 + self.extend_selection(new_pos); 229 + } else { 230 + self.set_cursor(new_pos); 231 + } 232 + } 233 + } 234 + 235 + /// Move cursor one word to the left (Option+Left on macOS). 236 + pub fn move_word_left(&mut self, extend: bool) { 237 + let new_pos = prev_word_boundary(&self.text, self.cursor); 238 + if extend { 239 + self.extend_selection(new_pos); 240 + } else { 241 + self.set_cursor(new_pos); 242 + } 243 + } 244 + 245 + /// Move cursor one word to the right (Option+Right on macOS). 246 + pub fn move_word_right(&mut self, extend: bool) { 247 + let new_pos = next_word_boundary(&self.text, self.cursor); 248 + if extend { 249 + self.extend_selection(new_pos); 250 + } else { 251 + self.set_cursor(new_pos); 252 + } 253 + } 254 + 255 + /// Move cursor to the start of the line (Home or Cmd+Left on macOS). 256 + pub fn move_to_line_start(&mut self, extend: bool) { 257 + let line_start = line_start_offset(&self.text, self.cursor); 258 + if extend { 259 + self.extend_selection(line_start); 260 + } else { 261 + self.set_cursor(line_start); 262 + } 263 + } 264 + 265 + /// Move cursor to the end of the line (End or Cmd+Right on macOS). 266 + pub fn move_to_line_end(&mut self, extend: bool) { 267 + let line_end = line_end_offset(&self.text, self.cursor); 268 + if extend { 269 + self.extend_selection(line_end); 270 + } else { 271 + self.set_cursor(line_end); 272 + } 273 + } 274 + 275 + /// Move cursor to the very start of the text (Cmd+Up on macOS). 276 + pub fn move_to_start(&mut self, extend: bool) { 277 + if extend { 278 + self.extend_selection(0); 279 + } else { 280 + self.set_cursor(0); 281 + } 282 + } 283 + 284 + /// Move cursor to the very end of the text (Cmd+Down on macOS). 285 + pub fn move_to_end(&mut self, extend: bool) { 286 + let end = self.text.len(); 287 + if extend { 288 + self.extend_selection(end); 289 + } else { 290 + self.set_cursor(end); 291 + } 292 + } 293 + 294 + /// Get the currently selected text, or empty string if no selection. 295 + pub fn selected_text(&self) -> &str { 296 + let (start, end) = self.selection_range(); 297 + &self.text[start..end] 298 + } 299 + 300 + /// Record the current value as the "on focus" value. 301 + /// Called when the element gains focus. 302 + pub fn record_focus_value(&mut self) { 303 + self.value_on_focus = self.text.clone(); 304 + } 305 + 306 + /// Whether the value has changed since the element gained focus. 307 + /// Used to decide whether to fire a `change` event on blur. 308 + pub fn changed_since_focus(&self) -> bool { 309 + self.text != self.value_on_focus 310 + } 311 + 312 + /// Whether the text has been modified since last `clear_dirty()`. 313 + pub fn is_dirty(&self) -> bool { 314 + self.dirty 315 + } 316 + 317 + /// Clear the dirty flag. 318 + pub fn clear_dirty(&mut self) { 319 + self.dirty = false; 320 + } 321 + 322 + /// Replace the entire text content (e.g., from JS `input.value = ...`). 323 + pub fn set_text(&mut self, text: &str) { 324 + self.text = text.to_string(); 325 + // Clamp cursor/anchor to new length. 326 + let len = self.text.len(); 327 + self.cursor = self.cursor.min(len); 328 + self.anchor = self.anchor.min(len); 329 + } 330 + 331 + /// Select the word at the given byte position (double-click). 332 + pub fn select_word_at(&mut self, pos: usize) { 333 + let pos = snap_to_char_boundary(&self.text, pos.min(self.text.len())); 334 + // Find the word boundaries around `pos`. If `pos` is on a word char, 335 + // select the word. Otherwise, select the run of non-word chars. 336 + let at_word = pos < self.text.len() && is_word_char(char_at_byte(&self.text, pos)); 337 + if at_word { 338 + self.anchor = word_start(&self.text, pos); 339 + self.cursor = word_end(&self.text, pos); 340 + } else if pos > 0 { 341 + // Check if the previous char is a word char. 342 + let prev = prev_char_boundary(&self.text, pos); 343 + if is_word_char(char_at_byte(&self.text, prev)) { 344 + self.anchor = word_start(&self.text, prev); 345 + self.cursor = word_end(&self.text, prev); 346 + } else { 347 + // On non-word chars: just place cursor. 348 + self.anchor = pos; 349 + self.cursor = pos; 350 + } 351 + } else { 352 + self.anchor = 0; 353 + self.cursor = 0; 354 + } 355 + self.direction = SelectionDirection::Forward; 356 + } 357 + } 358 + 359 + /// Manages input state for all editable form controls in a document. 360 + #[derive(Debug, Default)] 361 + pub struct InputStateMap { 362 + states: HashMap<NodeId, InputState>, 363 + } 364 + 365 + impl InputStateMap { 366 + pub fn new() -> Self { 367 + InputStateMap { 368 + states: HashMap::new(), 369 + } 370 + } 371 + 372 + /// Get or create the input state for a node. 373 + /// `default_text` is used if the state doesn't exist yet. 374 + pub fn get_or_create(&mut self, node: NodeId, default_text: &str) -> &mut InputState { 375 + self.states 376 + .entry(node) 377 + .or_insert_with(|| InputState::new(default_text)) 378 + } 379 + 380 + /// Get the input state for a node, if it exists. 381 + pub fn get(&self, node: NodeId) -> Option<&InputState> { 382 + self.states.get(&node) 383 + } 384 + 385 + /// Get a mutable reference to the input state for a node. 386 + pub fn get_mut(&mut self, node: NodeId) -> Option<&mut InputState> { 387 + self.states.get_mut(&node) 388 + } 389 + } 390 + 391 + // --------------------------------------------------------------------------- 392 + // Text navigation helpers 393 + // --------------------------------------------------------------------------- 394 + 395 + /// Snap a byte offset to the nearest valid char boundary (rounding down). 396 + fn snap_to_char_boundary(s: &str, pos: usize) -> usize { 397 + if pos >= s.len() { 398 + return s.len(); 399 + } 400 + let mut p = pos; 401 + while p > 0 && !s.is_char_boundary(p) { 402 + p -= 1; 403 + } 404 + p 405 + } 406 + 407 + /// Find the previous char boundary before `pos`. 408 + fn prev_char_boundary(s: &str, pos: usize) -> usize { 409 + if pos == 0 { 410 + return 0; 411 + } 412 + let mut p = pos - 1; 413 + while p > 0 && !s.is_char_boundary(p) { 414 + p -= 1; 415 + } 416 + p 417 + } 418 + 419 + /// Find the next char boundary after `pos`. 420 + fn next_char_boundary(s: &str, pos: usize) -> usize { 421 + if pos >= s.len() { 422 + return s.len(); 423 + } 424 + let mut p = pos + 1; 425 + while p < s.len() && !s.is_char_boundary(p) { 426 + p += 1; 427 + } 428 + p 429 + } 430 + 431 + fn is_word_char(c: char) -> bool { 432 + c.is_alphanumeric() || c == '_' 433 + } 434 + 435 + /// Find the start of the word before `pos`. 436 + fn prev_word_boundary(s: &str, pos: usize) -> usize { 437 + if pos == 0 { 438 + return 0; 439 + } 440 + let bytes = s.as_bytes(); 441 + let mut p = prev_char_boundary(s, pos); 442 + 443 + // Skip non-word characters. 444 + while p > 0 && !is_word_char(char_at_byte(s, p)) { 445 + p = prev_char_boundary(s, p); 446 + } 447 + // Skip word characters. 448 + while p > 0 { 449 + let prev = prev_char_boundary(s, p); 450 + if !is_word_char(char_at_byte(s, prev)) { 451 + break; 452 + } 453 + p = prev; 454 + } 455 + // Edge case: if we're at byte 0 and it's a word char, stay there. 456 + if p == 0 && !bytes.is_empty() && is_word_char(bytes[0] as char) { 457 + return 0; 458 + } 459 + p 460 + } 461 + 462 + /// Find the end of the word after `pos`. 463 + fn next_word_boundary(s: &str, pos: usize) -> usize { 464 + let len = s.len(); 465 + if pos >= len { 466 + return len; 467 + } 468 + let mut p = pos; 469 + 470 + // Skip word characters. 471 + while p < len { 472 + let c = char_at_byte(s, p); 473 + if !is_word_char(c) { 474 + break; 475 + } 476 + p = next_char_boundary(s, p); 477 + } 478 + // Skip non-word characters. 479 + while p < len { 480 + let c = char_at_byte(s, p); 481 + if is_word_char(c) { 482 + break; 483 + } 484 + p = next_char_boundary(s, p); 485 + } 486 + p 487 + } 488 + 489 + /// Get the char at a byte offset (assumes valid boundary). 490 + fn char_at_byte(s: &str, pos: usize) -> char { 491 + s[pos..].chars().next().unwrap_or('\0') 492 + } 493 + 494 + /// Find the start of the word containing `pos` (scan backward over word chars). 495 + fn word_start(s: &str, pos: usize) -> usize { 496 + let mut p = pos; 497 + while p > 0 { 498 + let prev = prev_char_boundary(s, p); 499 + if !is_word_char(char_at_byte(s, prev)) { 500 + break; 501 + } 502 + p = prev; 503 + } 504 + p 505 + } 506 + 507 + /// Find the end of the word containing `pos` (scan forward over word chars). 508 + fn word_end(s: &str, pos: usize) -> usize { 509 + let mut p = pos; 510 + while p < s.len() { 511 + let c = char_at_byte(s, p); 512 + if !is_word_char(c) { 513 + break; 514 + } 515 + p = next_char_boundary(s, p); 516 + } 517 + p 518 + } 519 + 520 + /// Find the start of the current line (backwards to the previous '\n' or 0). 521 + fn line_start_offset(s: &str, pos: usize) -> usize { 522 + let bytes = s.as_bytes(); 523 + let mut p = pos; 524 + while p > 0 { 525 + if bytes[p - 1] == b'\n' { 526 + return p; 527 + } 528 + p -= 1; 529 + } 530 + 0 531 + } 532 + 533 + /// Find the end of the current line (forward to the next '\n' or end). 534 + fn line_end_offset(s: &str, pos: usize) -> usize { 535 + let bytes = s.as_bytes(); 536 + let mut p = pos; 537 + while p < bytes.len() { 538 + if bytes[p] == b'\n' { 539 + return p; 540 + } 541 + p += 1; 542 + } 543 + s.len() 544 + } 545 + 546 + // --------------------------------------------------------------------------- 547 + // Tests 548 + // --------------------------------------------------------------------------- 549 + 550 + #[cfg(test)] 551 + mod tests { 552 + use super::*; 553 + 554 + #[test] 555 + fn new_state_cursor_at_end() { 556 + let state = InputState::new("hello"); 557 + assert_eq!(state.cursor(), 5); 558 + assert_eq!(state.anchor(), 5); 559 + assert!(!state.has_selection()); 560 + } 561 + 562 + #[test] 563 + fn insert_at_cursor() { 564 + let mut state = InputState::new("hello"); 565 + state.set_cursor(5); 566 + state.insert(" world"); 567 + assert_eq!(state.text(), "hello world"); 568 + assert_eq!(state.cursor(), 11); 569 + } 570 + 571 + #[test] 572 + fn insert_replaces_selection() { 573 + let mut state = InputState::new("hello world"); 574 + state.set_cursor(0); 575 + state.extend_selection(5); 576 + state.insert("hi"); 577 + assert_eq!(state.text(), "hi world"); 578 + assert_eq!(state.cursor(), 2); 579 + } 580 + 581 + #[test] 582 + fn delete_backward() { 583 + let mut state = InputState::new("hello"); 584 + state.set_cursor(5); 585 + state.delete_backward(); 586 + assert_eq!(state.text(), "hell"); 587 + assert_eq!(state.cursor(), 4); 588 + } 589 + 590 + #[test] 591 + fn delete_backward_at_start() { 592 + let mut state = InputState::new("hello"); 593 + state.set_cursor(0); 594 + assert!(!state.delete_backward()); 595 + assert_eq!(state.text(), "hello"); 596 + } 597 + 598 + #[test] 599 + fn delete_forward() { 600 + let mut state = InputState::new("hello"); 601 + state.set_cursor(0); 602 + state.delete_forward(); 603 + assert_eq!(state.text(), "ello"); 604 + assert_eq!(state.cursor(), 0); 605 + } 606 + 607 + #[test] 608 + fn delete_forward_at_end() { 609 + let mut state = InputState::new("hello"); 610 + state.set_cursor(5); 611 + assert!(!state.delete_forward()); 612 + assert_eq!(state.text(), "hello"); 613 + } 614 + 615 + #[test] 616 + fn delete_selection() { 617 + let mut state = InputState::new("hello world"); 618 + state.set_cursor(5); 619 + state.extend_selection(11); 620 + state.delete_backward(); 621 + assert_eq!(state.text(), "hello"); 622 + } 623 + 624 + #[test] 625 + fn move_left_right() { 626 + let mut state = InputState::new("hello"); 627 + state.set_cursor(3); 628 + state.move_left(false); 629 + assert_eq!(state.cursor(), 2); 630 + state.move_right(false); 631 + assert_eq!(state.cursor(), 3); 632 + } 633 + 634 + #[test] 635 + fn move_left_collapses_selection() { 636 + let mut state = InputState::new("hello"); 637 + state.set_cursor(1); 638 + state.extend_selection(4); 639 + state.move_left(false); 640 + assert_eq!(state.cursor(), 1); 641 + assert!(!state.has_selection()); 642 + } 643 + 644 + #[test] 645 + fn move_right_collapses_selection() { 646 + let mut state = InputState::new("hello"); 647 + state.set_cursor(1); 648 + state.extend_selection(4); 649 + state.move_right(false); 650 + assert_eq!(state.cursor(), 4); 651 + assert!(!state.has_selection()); 652 + } 653 + 654 + #[test] 655 + fn select_all() { 656 + let mut state = InputState::new("hello"); 657 + state.select_all(); 658 + assert!(state.has_selection()); 659 + assert_eq!(state.selection_range(), (0, 5)); 660 + assert_eq!(state.selected_text(), "hello"); 661 + } 662 + 663 + #[test] 664 + fn extend_selection_left() { 665 + let mut state = InputState::new("hello"); 666 + state.set_cursor(3); 667 + state.move_left(true); 668 + assert!(state.has_selection()); 669 + assert_eq!(state.selection_range(), (2, 3)); 670 + assert_eq!(state.selected_text(), "l"); 671 + } 672 + 673 + #[test] 674 + fn word_navigation() { 675 + let mut state = InputState::new("hello world foo"); 676 + state.set_cursor(0); 677 + state.move_word_right(false); 678 + // After "hello" + whitespace, should be at start of "world" 679 + assert_eq!(state.cursor(), 6); 680 + state.move_word_right(false); 681 + assert_eq!(state.cursor(), 12); 682 + state.move_word_left(false); 683 + assert_eq!(state.cursor(), 6); 684 + } 685 + 686 + #[test] 687 + fn delete_word_backward() { 688 + let mut state = InputState::new("hello world"); 689 + state.set_cursor(11); 690 + state.delete_word_backward(); 691 + assert_eq!(state.text(), "hello "); 692 + } 693 + 694 + #[test] 695 + fn line_navigation() { 696 + let mut state = InputState::new("line1\nline2\nline3"); 697 + state.set_cursor(8); // middle of "line2" 698 + state.move_to_line_start(false); 699 + assert_eq!(state.cursor(), 6); // start of "line2" 700 + state.move_to_line_end(false); 701 + assert_eq!(state.cursor(), 11); // end of "line2" (before \n) 702 + } 703 + 704 + #[test] 705 + fn move_to_start_end() { 706 + let mut state = InputState::new("hello world"); 707 + state.set_cursor(5); 708 + state.move_to_start(false); 709 + assert_eq!(state.cursor(), 0); 710 + state.move_to_end(false); 711 + assert_eq!(state.cursor(), 11); 712 + } 713 + 714 + #[test] 715 + fn delete_to_line_start() { 716 + let mut state = InputState::new("hello world"); 717 + state.set_cursor(5); 718 + state.delete_to_line_start(); 719 + assert_eq!(state.text(), " world"); 720 + assert_eq!(state.cursor(), 0); 721 + } 722 + 723 + #[test] 724 + fn select_word_at() { 725 + let mut state = InputState::new("hello world"); 726 + state.select_word_at(3); // in the middle of "hello" 727 + assert_eq!(state.selected_text(), "hello"); 728 + } 729 + 730 + #[test] 731 + fn change_detection() { 732 + let mut state = InputState::new("hello"); 733 + state.record_focus_value(); 734 + assert!(!state.changed_since_focus()); 735 + state.set_cursor(5); 736 + state.insert("!"); 737 + assert!(state.changed_since_focus()); 738 + } 739 + 740 + #[test] 741 + fn unicode_navigation() { 742 + let mut state = InputState::new("héllo"); 743 + state.set_cursor(0); 744 + state.move_right(false); 745 + assert_eq!(state.cursor(), 1); // 'h' is 1 byte 746 + state.move_right(false); 747 + assert_eq!(state.cursor(), 3); // 'é' is 2 bytes 748 + state.move_left(false); 749 + assert_eq!(state.cursor(), 1); 750 + } 751 + 752 + #[test] 753 + fn input_state_map_get_or_create() { 754 + let mut map = InputStateMap::new(); 755 + let node = NodeId::from_index(42); 756 + { 757 + let state = map.get_or_create(node, "default"); 758 + assert_eq!(state.text(), "default"); 759 + state.set_cursor(0); 760 + state.insert("new "); 761 + } 762 + // Subsequent get_or_create should return existing state, not reset. 763 + let state = map.get_or_create(node, "default"); 764 + assert_eq!(state.text(), "new default"); 765 + } 766 + 767 + #[test] 768 + fn set_text_clamps_cursor() { 769 + let mut state = InputState::new("long text here"); 770 + state.set_cursor(14); 771 + state.set_text("hi"); 772 + assert_eq!(state.cursor(), 2); 773 + assert_eq!(state.text(), "hi"); 774 + } 775 + }
+13 -1
crates/dom/src/lib.rs
··· 6 6 //! Tag names and attribute names are interned via `Atom` for memory efficiency: 7 7 //! thousands of `<div>` elements share one string allocation instead of one each. 8 8 9 + pub mod input_state; 10 + 9 11 use std::fmt; 10 12 11 13 use we_memory::intern::Atom; 14 + 15 + pub use input_state::{InputState, InputStateMap}; 12 16 13 17 /// A handle to a node in the DOM tree. 14 18 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] ··· 74 78 active_element: Option<NodeId>, 75 79 /// Whether focus was set via keyboard navigation (for `:focus-visible`). 76 80 focus_visible: bool, 81 + /// Per-element editing state for text inputs and textareas. 82 + pub input_states: InputStateMap, 77 83 } 78 84 79 85 impl fmt::Debug for Document { ··· 101 107 root: NodeId(0), 102 108 active_element: None, 103 109 focus_visible: false, 110 + input_states: InputStateMap::new(), 104 111 } 105 112 } 106 113 ··· 606 613 let mut zero_or_natural: Vec<(usize, NodeId)> = Vec::new(); 607 614 let mut dom_order = 0usize; 608 615 609 - self.collect_tab_order(self.root, &mut dom_order, &mut positive, &mut zero_or_natural); 616 + self.collect_tab_order( 617 + self.root, 618 + &mut dom_order, 619 + &mut positive, 620 + &mut zero_or_natural, 621 + ); 610 622 611 623 // Sort positive tabindex by (tabindex, dom_order). 612 624 positive.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
+1 -4
crates/js/src/dom_bridge.rs
··· 969 969 970 970 let is_active = bridge.document.borrow().active_element() == Some(node_id); 971 971 if is_active { 972 - bridge 973 - .document 974 - .borrow_mut() 975 - .set_active_element(None, false); 972 + bridge.document.borrow_mut().set_active_element(None, false); 976 973 } 977 974 Ok(Value::Undefined) 978 975 }
+7 -1
crates/js/src/vm.rs
··· 2659 2659 return Some(val); 2660 2660 } 2661 2661 // Try document-level dynamic properties (e.g. document.cookie, document.activeElement). 2662 - crate::dom_bridge::resolve_document_get(&mut self.gc, &mut self.shapes, &bridge, gc_ref, key) 2662 + crate::dom_bridge::resolve_document_get( 2663 + &mut self.gc, 2664 + &mut self.shapes, 2665 + &bridge, 2666 + gc_ref, 2667 + key, 2668 + ) 2663 2669 } 2664 2670 2665 2671 /// Handle a DOM property set on a wrapper object.
+47 -3
crates/layout/src/lib.rs
··· 105 105 pub disabled: bool, 106 106 /// Whether the control currently has focus. 107 107 pub focused: bool, 108 + /// Cursor byte offset into `value` (for text inputs/textareas when focused). 109 + pub cursor: usize, 110 + /// Selection anchor byte offset (equal to cursor if no selection). 111 + pub selection_anchor: usize, 108 112 } 109 113 110 114 /// A box in the layout tree with dimensions and child boxes. ··· 650 654 fn build_form_control_info(node: NodeId, doc: &Document) -> Option<FormControlInfo> { 651 655 let tag = doc.tag_name(node)?; 652 656 let focused = doc.active_element() == Some(node); 657 + // For editable text controls, prefer the InputState's text over the DOM attribute. 658 + let input_state = doc.input_states.get(node); 653 659 match tag { 654 660 "input" => { 655 661 let input_type = doc.get_attribute(node, "type").unwrap_or("text"); ··· 663 669 checked, 664 670 disabled, 665 671 focused, 672 + cursor: 0, 673 + selection_anchor: 0, 666 674 }), 667 675 "radio" => Some(FormControlInfo { 668 676 control_type: FormControlType::Radio, ··· 670 678 checked, 671 679 disabled, 672 680 focused, 681 + cursor: 0, 682 + selection_anchor: 0, 673 683 }), 674 684 "submit" => { 675 685 let value = doc ··· 682 692 checked: false, 683 693 disabled, 684 694 focused, 695 + cursor: 0, 696 + selection_anchor: 0, 685 697 }) 686 698 } 687 699 "reset" => { ··· 695 707 checked: false, 696 708 disabled, 697 709 focused, 710 + cursor: 0, 711 + selection_anchor: 0, 698 712 }) 699 713 } 700 714 "button" => { ··· 705 719 checked: false, 706 720 disabled, 707 721 focused, 722 + cursor: 0, 723 + selection_anchor: 0, 708 724 }) 709 725 } 710 726 "password" => { 711 - let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); 727 + let (value, cursor, anchor) = if let Some(is) = input_state { 728 + (is.text().to_string(), is.cursor(), is.anchor()) 729 + } else { 730 + let v = doc.get_attribute(node, "value").unwrap_or("").to_string(); 731 + let len = v.len(); 732 + (v, len, len) 733 + }; 712 734 Some(FormControlInfo { 713 735 control_type: FormControlType::Password, 714 736 value, 715 737 checked: false, 716 738 disabled, 717 739 focused, 740 + cursor, 741 + selection_anchor: anchor, 718 742 }) 719 743 } 720 744 // text, email, url, search, tel, number, etc. 721 745 _ => { 722 - let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); 746 + let (value, cursor, anchor) = if let Some(is) = input_state { 747 + (is.text().to_string(), is.cursor(), is.anchor()) 748 + } else { 749 + let v = doc.get_attribute(node, "value").unwrap_or("").to_string(); 750 + let len = v.len(); 751 + (v, len, len) 752 + }; 723 753 Some(FormControlInfo { 724 754 control_type: FormControlType::TextInput, 725 755 value, 726 756 checked: false, 727 757 disabled, 728 758 focused, 759 + cursor, 760 + selection_anchor: anchor, 729 761 }) 730 762 } 731 763 } 732 764 } 733 765 "textarea" => { 734 766 let disabled = doc.get_attribute(node, "disabled").is_some(); 735 - let value = collect_text_content(doc, node); 767 + let (value, cursor, anchor) = if let Some(is) = input_state { 768 + (is.text().to_string(), is.cursor(), is.anchor()) 769 + } else { 770 + let v = collect_text_content(doc, node); 771 + let len = v.len(); 772 + (v, len, len) 773 + }; 736 774 Some(FormControlInfo { 737 775 control_type: FormControlType::Textarea, 738 776 value, 739 777 checked: false, 740 778 disabled, 741 779 focused, 780 + cursor, 781 + selection_anchor: anchor, 742 782 }) 743 783 } 744 784 "select" => { ··· 751 791 checked: false, 752 792 disabled, 753 793 focused, 794 + cursor: 0, 795 + selection_anchor: 0, 754 796 }) 755 797 } 756 798 "button" => { ··· 767 809 checked: false, 768 810 disabled, 769 811 focused, 812 + cursor: 0, 813 + selection_anchor: 0, 770 814 }) 771 815 } 772 816 _ => None,
+198 -13
crates/platform/src/appkit.rs
··· 272 272 let c_str = unsafe { CStr::from_ptr(utf8) }; 273 273 let key_code: u16 = msg_send![event, keyCode]; 274 274 let modifier_flags: u64 = msg_send![event, modifierFlags]; 275 - let shift = (modifier_flags & 0x20000) != 0; // NSEventModifierFlagShift 275 + let modifiers = KeyModifiers::from_flags(modifier_flags); 276 276 if let Ok(s) = c_str.to_str() { 277 277 // SAFETY: We are on the main thread (AppKit event loop). 278 278 unsafe { 279 279 if let Some(handler) = KEY_HANDLER { 280 - handler(key_code, s, shift); 280 + handler(key_code, s, modifiers); 281 281 } 282 282 } 283 283 } 284 284 } 285 285 286 - /// `mouseDown:` — log mouse location to stdout. 286 + /// `mouseDown:` — dispatch mouse-down event to registered handler. 287 287 extern "C" fn view_mouse_down(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 288 288 let raw_loc: NSPoint = msg_send![event, locationInWindow]; 289 289 let loc: NSPoint = 290 290 msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::<c_void>()]; 291 - println!("mouseDown: ({:.1}, {:.1})", loc.x, loc.y); 291 + let click_count: u64 = msg_send![event, clickCount]; 292 + let modifier_flags: u64 = msg_send![event, modifierFlags]; 293 + let modifiers = KeyModifiers::from_flags(modifier_flags); 294 + // SAFETY: We are on the main thread (AppKit event loop). 295 + unsafe { 296 + if let Some(handler) = MOUSE_DOWN_HANDLER { 297 + handler(loc.x, loc.y, click_count as u32, modifiers); 298 + } 299 + } 292 300 } 293 301 294 - /// `mouseUp:` — log mouse location to stdout. 302 + /// `mouseUp:` — dispatch mouse-up event to registered handler. 295 303 extern "C" fn view_mouse_up(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 296 304 let raw_loc: NSPoint = msg_send![event, locationInWindow]; 297 - let loc: NSPoint = 305 + let _loc: NSPoint = 298 306 msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::<c_void>()]; 299 - println!("mouseUp: ({:.1}, {:.1})", loc.x, loc.y); 300 307 } 301 308 302 - /// `mouseMoved:` — log mouse location to stdout. 309 + /// `mouseMoved:` — no-op (needed for `acceptsMouseMovedEvents`). 303 310 extern "C" fn view_mouse_moved(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 304 311 let raw_loc: NSPoint = msg_send![event, locationInWindow]; 312 + let _loc: NSPoint = 313 + msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::<c_void>()]; 314 + } 315 + 316 + /// `mouseDragged:` — dispatch mouse-drag event to registered handler. 317 + extern "C" fn view_mouse_dragged(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 318 + let raw_loc: NSPoint = msg_send![event, locationInWindow]; 305 319 let loc: NSPoint = 306 320 msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::<c_void>()]; 307 - println!("mouseMoved: ({:.1}, {:.1})", loc.x, loc.y); 321 + // SAFETY: We are on the main thread (AppKit event loop). 322 + unsafe { 323 + if let Some(handler) = MOUSE_DRAGGED_HANDLER { 324 + handler(loc.x, loc.y); 325 + } 326 + } 308 327 } 309 328 310 329 /// `scrollWheel:` — call scroll handler with delta and mouse location. ··· 373 392 view_class.add_method( 374 393 sel, 375 394 unsafe { std::mem::transmute::<*const (), Imp>(view_scroll_wheel as *const ()) }, 395 + c"v@:@", 396 + ); 397 + 398 + let sel = Sel::register(c"mouseDragged:"); 399 + view_class.add_method( 400 + sel, 401 + unsafe { std::mem::transmute::<*const (), Imp>(view_mouse_dragged as *const ()) }, 376 402 c"v@:@", 377 403 ); 378 404 } ··· 760 786 } 761 787 762 788 // --------------------------------------------------------------------------- 789 + // Keyboard modifier flags 790 + // --------------------------------------------------------------------------- 791 + 792 + /// Modifier key state for keyboard and mouse events. 793 + #[derive(Debug, Clone, Copy, Default)] 794 + pub struct KeyModifiers { 795 + pub shift: bool, 796 + pub command: bool, 797 + pub option: bool, 798 + pub control: bool, 799 + } 800 + 801 + impl KeyModifiers { 802 + /// Extract modifier flags from an NSEvent `modifierFlags` bitmask. 803 + pub fn from_flags(flags: u64) -> Self { 804 + KeyModifiers { 805 + shift: (flags & 0x20000) != 0, // NSEventModifierFlagShift 806 + command: (flags & 0x100000) != 0, // NSEventModifierFlagCommand 807 + option: (flags & 0x80000) != 0, // NSEventModifierFlagOption 808 + control: (flags & 0x40000) != 0, // NSEventModifierFlagControl 809 + } 810 + } 811 + } 812 + 813 + // --------------------------------------------------------------------------- 763 814 // Global resize handler 764 815 // --------------------------------------------------------------------------- 765 816 ··· 803 854 } 804 855 805 856 /// Global key-down callback, called from `keyDown:` with the key code, 806 - /// character string, and whether the shift key was held. 857 + /// character string, and modifier key state. 807 858 /// 808 859 /// # Safety 809 860 /// 810 861 /// Accessed only from the main thread (the AppKit event loop). 811 - static mut KEY_HANDLER: Option<fn(u16, &str, bool)> = None; 862 + static mut KEY_HANDLER: Option<fn(u16, &str, KeyModifiers)> = None; 812 863 813 864 /// Register a function to be called when a key-down event occurs. 814 865 /// 815 - /// The handler receives `(key_code, characters, shift_held)`. 866 + /// The handler receives `(key_code, characters, modifiers)`. 816 867 /// Only one handler can be active at a time. 817 - pub fn set_key_handler(handler: fn(u16, &str, bool)) { 868 + pub fn set_key_handler(handler: fn(u16, &str, KeyModifiers)) { 818 869 // SAFETY: Called from the main thread before `app.run()`. 819 870 unsafe { 820 871 KEY_HANDLER = Some(handler); 821 872 } 822 873 } 823 874 875 + /// Global mouse-down callback, called from `mouseDown:` with the click 876 + /// location in view coordinates, click count, and modifier flags. 877 + /// 878 + /// # Safety 879 + /// 880 + /// Accessed only from the main thread (the AppKit event loop). 881 + static mut MOUSE_DOWN_HANDLER: Option<fn(f64, f64, u32, KeyModifiers)> = None; 882 + 883 + /// Register a function to be called when a mouse-down event occurs. 884 + /// 885 + /// The handler receives `(x, y, click_count, modifiers)`. 886 + /// Only one handler can be active at a time. 887 + pub fn set_mouse_down_handler(handler: fn(f64, f64, u32, KeyModifiers)) { 888 + // SAFETY: Called from the main thread before `app.run()`. 889 + unsafe { 890 + MOUSE_DOWN_HANDLER = Some(handler); 891 + } 892 + } 893 + 894 + /// Global mouse-dragged callback, called from `mouseDragged:` with the 895 + /// current drag location in view coordinates. 896 + /// 897 + /// # Safety 898 + /// 899 + /// Accessed only from the main thread (the AppKit event loop). 900 + static mut MOUSE_DRAGGED_HANDLER: Option<fn(f64, f64)> = None; 901 + 902 + /// Register a function to be called when the mouse is dragged. 903 + /// 904 + /// The handler receives `(x, y)`. 905 + /// Only one handler can be active at a time. 906 + pub fn set_mouse_dragged_handler(handler: fn(f64, f64)) { 907 + // SAFETY: Called from the main thread before `app.run()`. 908 + unsafe { 909 + MOUSE_DRAGGED_HANDLER = Some(handler); 910 + } 911 + } 912 + 824 913 // --------------------------------------------------------------------------- 825 914 // Window delegate for handling resize and close events 826 915 // --------------------------------------------------------------------------- ··· 1019 1108 } 1020 1109 1021 1110 // --------------------------------------------------------------------------- 1111 + // Clipboard (NSPasteboard) 1112 + // --------------------------------------------------------------------------- 1113 + 1114 + /// Read a UTF-8 string from the general pasteboard (system clipboard). 1115 + /// 1116 + /// Returns `None` if the pasteboard does not contain a string. 1117 + pub fn clipboard_get_string() -> Option<String> { 1118 + let cls = class!("NSPasteboard")?; 1119 + let pb: *mut c_void = msg_send![cls.as_ptr(), generalPasteboard]; 1120 + if pb.is_null() { 1121 + return None; 1122 + } 1123 + // [pb stringForType:NSPasteboardTypeString] 1124 + let ns_string_type = CfString::new("public.utf8-plain-text")?; 1125 + let string: *mut c_void = msg_send![pb, stringForType: ns_string_type.as_void_ptr()]; 1126 + if string.is_null() { 1127 + return None; 1128 + } 1129 + let utf8: *const c_char = msg_send![string, UTF8String]; 1130 + if utf8.is_null() { 1131 + return None; 1132 + } 1133 + let c_str = unsafe { CStr::from_ptr(utf8) }; 1134 + c_str.to_str().ok().map(|s| s.to_string()) 1135 + } 1136 + 1137 + /// Write a UTF-8 string to the general pasteboard (system clipboard). 1138 + /// 1139 + /// Returns `true` on success. 1140 + pub fn clipboard_set_string(text: &str) -> bool { 1141 + let cls = match class!("NSPasteboard") { 1142 + Some(c) => c, 1143 + None => return false, 1144 + }; 1145 + let pb: *mut c_void = msg_send![cls.as_ptr(), generalPasteboard]; 1146 + if pb.is_null() { 1147 + return false; 1148 + } 1149 + // [pb clearContents] 1150 + let _: *mut c_void = msg_send![pb, clearContents]; 1151 + // [pb setString:str forType:NSPasteboardTypeString] 1152 + let ns_string = match CfString::new(text) { 1153 + Some(s) => s, 1154 + None => return false, 1155 + }; 1156 + let ns_type = match CfString::new("public.utf8-plain-text") { 1157 + Some(s) => s, 1158 + None => return false, 1159 + }; 1160 + let result: bool = msg_send![ 1161 + pb, 1162 + setString: ns_string.as_void_ptr(), 1163 + forType: ns_type.as_void_ptr() 1164 + ]; 1165 + result 1166 + } 1167 + 1168 + // --------------------------------------------------------------------------- 1022 1169 // Tests 1023 1170 // --------------------------------------------------------------------------- 1024 1171 ··· 1136 1283 responds, 1137 1284 "WeWindowDelegate should respond to windowDidResize:" 1138 1285 ); 1286 + } 1287 + 1288 + #[test] 1289 + fn key_modifiers_from_flags() { 1290 + let mods = KeyModifiers::from_flags(0); 1291 + assert!(!mods.shift); 1292 + assert!(!mods.command); 1293 + assert!(!mods.option); 1294 + assert!(!mods.control); 1295 + 1296 + // Shift 1297 + let mods = KeyModifiers::from_flags(0x20000); 1298 + assert!(mods.shift); 1299 + assert!(!mods.command); 1300 + 1301 + // Command 1302 + let mods = KeyModifiers::from_flags(0x100000); 1303 + assert!(mods.command); 1304 + assert!(!mods.shift); 1305 + 1306 + // Option 1307 + let mods = KeyModifiers::from_flags(0x80000); 1308 + assert!(mods.option); 1309 + 1310 + // Combined 1311 + let mods = KeyModifiers::from_flags(0x20000 | 0x100000); 1312 + assert!(mods.shift); 1313 + assert!(mods.command); 1314 + } 1315 + 1316 + #[test] 1317 + fn we_view_responds_to_mouse_dragged() { 1318 + let _pool = AutoreleasePool::new(); 1319 + register_we_view_class(); 1320 + let cls = class!("WeView").expect("WeView should be registered"); 1321 + let sel = Sel::register(c"mouseDragged:"); 1322 + let responds: bool = msg_send![cls.as_ptr(), instancesRespondToSelector: sel.as_ptr()]; 1323 + assert!(responds, "WeView should respond to mouseDragged:"); 1139 1324 } 1140 1325 }
+70 -8
crates/render/src/lib.rs
··· 706 706 b: 204, 707 707 a: 255, 708 708 }; 709 + /// Text selection highlight color (standard macOS blue selection). 710 + const FC_SELECTION_COLOR: Color = Color { 711 + r: 179, 712 + g: 215, 713 + b: 254, 714 + a: 255, 715 + }; 716 + /// Text cursor (caret) color. 717 + const FC_CURSOR_COLOR: Color = Color { 718 + r: 0, 719 + g: 0, 720 + b: 0, 721 + a: 255, 722 + }; 709 723 710 724 /// Paint a form control with native-style appearance. 711 725 fn paint_form_control( ··· 857 871 color: FC_BUTTON_BORDER_LIGHT, 858 872 }); 859 873 860 - // Value text (clipped to content area) 861 - if !fc.value.is_empty() { 862 - let display_text = if fc.control_type == FormControlType::Password { 863 - "\u{2022}".repeat(fc.value.len()) // bullet characters 874 + // Content area for text. 875 + let font_size = layout_box.font_size; 876 + let text_x = layout_box.rect.x + tx; 877 + let text_y = layout_box.rect.y + ty; 878 + let content_h = layout_box.rect.height; 879 + 880 + // For password fields, map byte offsets through the bullet-character mapping. 881 + let display_text = if fc.control_type == FormControlType::Password { 882 + "\u{2022}".repeat(fc.value.chars().count()) 883 + } else { 884 + fc.value.clone() 885 + }; 886 + 887 + // Approximate character width (monospace assumption for cursor positioning). 888 + let char_width = font_size * 0.6; 889 + 890 + // Paint selection highlight (behind text) when focused and selection exists. 891 + if fc.focused && fc.cursor != fc.selection_anchor { 892 + let (sel_start, sel_end) = if fc.cursor <= fc.selection_anchor { 893 + (fc.cursor, fc.selection_anchor) 864 894 } else { 865 - fc.value.clone() 895 + (fc.selection_anchor, fc.cursor) 866 896 }; 867 - let font_size = layout_box.font_size; 868 - let text_x = layout_box.rect.x + tx; 869 - let text_y = layout_box.rect.y + ty; 897 + let start_chars = char_count_for_bytes(&fc.value, sel_start); 898 + let end_chars = char_count_for_bytes(&fc.value, sel_end); 899 + let sel_x = text_x + start_chars as f32 * char_width; 900 + let sel_w = (end_chars - start_chars) as f32 * char_width; 901 + list.push(PaintCommand::FillRect { 902 + x: sel_x, 903 + y: text_y, 904 + width: sel_w, 905 + height: content_h, 906 + color: FC_SELECTION_COLOR, 907 + }); 908 + } 909 + 910 + // Value text (clipped to content area). 911 + if !display_text.is_empty() { 870 912 list.push(PaintCommand::DrawGlyphs { 871 913 line: TextLine { 872 914 text: display_text, ··· 882 924 color: text_color, 883 925 }); 884 926 } 927 + 928 + // Paint cursor (caret) when focused and no selection. 929 + if fc.focused && fc.cursor == fc.selection_anchor { 930 + let cursor_chars = char_count_for_bytes(&fc.value, fc.cursor); 931 + let cursor_x = text_x + cursor_chars as f32 * char_width; 932 + let cursor_w = 1.0f32; 933 + list.push(PaintCommand::FillRect { 934 + x: cursor_x, 935 + y: text_y, 936 + width: cursor_w, 937 + height: content_h, 938 + color: FC_CURSOR_COLOR, 939 + }); 940 + } 941 + } 942 + 943 + /// Count the number of Unicode characters in `s[..byte_offset]`. 944 + fn char_count_for_bytes(s: &str, byte_offset: usize) -> usize { 945 + let clamped = byte_offset.min(s.len()); 946 + s[..clamped].chars().count() 885 947 } 886 948 887 949 /// Paint a textarea: same style as text input but taller.
+2 -6
crates/style/src/matching.rs
··· 206 206 match name { 207 207 "focus" => doc.active_element() == Some(node), 208 208 "focus-visible" => doc.active_element() == Some(node) && doc.is_focus_visible(), 209 - "focus-within" => { 210 - doc.active_element() == Some(node) || doc.is_focus_within(node) 211 - } 209 + "focus-within" => doc.active_element() == Some(node) || doc.is_focus_within(node), 212 210 "disabled" => doc.get_attribute(node, "disabled").is_some(), 213 211 "enabled" => { 214 212 matches!( ··· 216 214 Some("input" | "select" | "textarea" | "button") 217 215 ) && doc.get_attribute(node, "disabled").is_none() 218 216 } 219 - "checked" => { 220 - doc.get_attribute(node, "checked").is_some() 221 - } 217 + "checked" => doc.get_attribute(node, "checked").is_some(), 222 218 _ => false, 223 219 } 224 220 }