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 checkbox and radio button interaction (Phase 16)

- Checkbox click/space toggles checked state, clears indeterminate
- Radio button click/space selects and deselects siblings in same name group
- Arrow keys (Up/Left/Down/Right) cycle focus within radio groups
- Label click delegates to associated control (for attribute or descendant)
- Indeterminate visual state for checkboxes (horizontal dash)
- Hit-test extended to detect all form control types (not just text inputs)
- General element hit-test added for label click delegation
- Tests for radio group membership, indeterminate state, and grouping rules

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

+428 -25
+214 -24
crates/browser/src/main.rs
··· 316 316 const KEY_CODE_HOME: u16 = 115; 317 317 const KEY_CODE_END: u16 = 119; 318 318 const KEY_CODE_RETURN: u16 = 36; 319 + const KEY_CODE_SPACE: u16 = 49; 319 320 const KEY_CODE_A: u16 = 0; 320 321 const KEY_CODE_C: u16 = 8; 321 322 const KEY_CODE_V: u16 = 9; ··· 362 363 } 363 364 } 364 365 366 + /// Returns the input type if `node` is a checkbox or radio, `None` otherwise. 367 + fn checkbox_or_radio_type(doc: &we_dom::Document, node: NodeId) -> Option<&str> { 368 + if doc.tag_name(node) != Some("input") { 369 + return None; 370 + } 371 + let t = doc.get_attribute(node, "type").unwrap_or("text"); 372 + if t.eq_ignore_ascii_case("checkbox") || t.eq_ignore_ascii_case("radio") { 373 + Some(t) 374 + } else { 375 + None 376 + } 377 + } 378 + 379 + /// Toggle a checkbox's checked state. Clears indeterminate on user activation. 380 + fn toggle_checkbox(doc: &mut we_dom::Document, node: NodeId) { 381 + if doc.get_attribute(node, "checked").is_some() { 382 + doc.remove_attribute(node, "checked"); 383 + } else { 384 + doc.set_attribute(node, "checked", ""); 385 + } 386 + // User interaction clears indeterminate state. 387 + doc.set_indeterminate(node, false); 388 + } 389 + 390 + /// Select a radio button and deselect all others in the same name group. 391 + fn select_radio(doc: &mut we_dom::Document, node: NodeId) { 392 + let members = doc.radio_group_members(node); 393 + for &member in &members { 394 + if member == node { 395 + doc.set_attribute(member, "checked", ""); 396 + } else { 397 + doc.remove_attribute(member, "checked"); 398 + } 399 + } 400 + } 401 + 402 + /// Cycle focus and selection through a radio group. 403 + /// `forward` = true means Down/Right, false means Up/Left. 404 + fn cycle_radio_group(state: &mut BrowserState, node: NodeId, forward: bool) { 405 + let members = state.page.doc.radio_group_members(node); 406 + if members.len() <= 1 { 407 + return; 408 + } 409 + let pos = match members.iter().position(|&m| m == node) { 410 + Some(p) => p, 411 + None => return, 412 + }; 413 + // Skip disabled members. 414 + let len = members.len(); 415 + let mut next_pos = pos; 416 + for _ in 0..len { 417 + next_pos = if forward { 418 + (next_pos + 1) % len 419 + } else { 420 + (next_pos + len - 1) % len 421 + }; 422 + let candidate = members[next_pos]; 423 + if state 424 + .page 425 + .doc 426 + .get_attribute(candidate, "disabled") 427 + .is_none() 428 + { 429 + select_radio(&mut state.page.doc, candidate); 430 + state.page.doc.set_active_element(Some(candidate), true); 431 + return; 432 + } 433 + } 434 + } 435 + 436 + /// Activate a form control: toggle checkbox, select radio, or focus text input. 437 + /// Used for label click delegation and direct control clicks. 438 + fn activate_control(state: &mut BrowserState, control: NodeId) { 439 + if state.page.doc.get_attribute(control, "disabled").is_some() { 440 + return; 441 + } 442 + state.page.doc.set_active_element(Some(control), false); 443 + 444 + if let Some(t) = checkbox_or_radio_type(&state.page.doc, control) { 445 + if t.eq_ignore_ascii_case("checkbox") { 446 + toggle_checkbox(&mut state.page.doc, control); 447 + } else { 448 + select_radio(&mut state.page.doc, control); 449 + } 450 + } else if is_text_editable(&state.page.doc, control) { 451 + ensure_input_state(&mut state.page.doc, control); 452 + if let Some(is) = state.page.doc.input_states.get_mut(control) { 453 + is.select_all(); 454 + is.record_focus_value(); 455 + } 456 + } 457 + } 458 + 365 459 /// Re-render the page and mark the view as needing display. 366 460 fn rerender(state: &mut BrowserState) { 367 461 let viewport_width = state.bitmap.width() as f32; ··· 397 491 None => return, 398 492 }; 399 493 400 - // Only process editing keys when a text control is focused. 401 494 let focused = match state.page.doc.active_element() { 402 495 Some(n) => n, 403 496 None => return, 404 497 }; 498 + 499 + // Handle checkbox/radio keyboard interactions. 500 + if let Some(input_type) = checkbox_or_radio_type(&state.page.doc, focused) { 501 + if state.page.doc.get_attribute(focused, "disabled").is_some() { 502 + return; 503 + } 504 + if input_type.eq_ignore_ascii_case("checkbox") { 505 + // Space toggles checkbox. 506 + if key_code == KEY_CODE_SPACE && !mods.command && !mods.control { 507 + toggle_checkbox(&mut state.page.doc, focused); 508 + rerender(state); 509 + } 510 + } else { 511 + // Radio button. 512 + if key_code == KEY_CODE_SPACE && !mods.command && !mods.control { 513 + // Space selects the focused radio. 514 + select_radio(&mut state.page.doc, focused); 515 + rerender(state); 516 + } else if matches!(key_code, KEY_CODE_UP | KEY_CODE_LEFT) { 517 + cycle_radio_group(state, focused, false); 518 + rerender(state); 519 + } else if matches!(key_code, KEY_CODE_DOWN | KEY_CODE_RIGHT) { 520 + cycle_radio_group(state, focused, true); 521 + rerender(state); 522 + } 523 + } 524 + return; 525 + } 526 + 527 + // Only process editing keys when a text control is focused. 405 528 if !is_text_editable(&state.page.doc, focused) { 406 529 return; 407 530 } ··· 693 816 &img_sizes, 694 817 ); 695 818 696 - // Hit-test the layout tree. 819 + // Hit-test the layout tree for any form control. 697 820 if let Some((node, local_x, _content_width, font_size)) = 698 821 hit_test_form_control(&tree.root, view_x, view_y, 0.0, 0.0) 699 822 { 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) { 823 + if checkbox_or_radio_type(&state.page.doc, node).is_some() { 824 + // Checkbox or radio button click. 825 + activate_control(state, node); 826 + } else if is_text_editable(&state.page.doc, node) { 827 + // Text input click. 828 + let was_focused = state.page.doc.active_element() == Some(node); 829 + state.page.doc.set_active_element(Some(node), false); 705 830 ensure_input_state(&mut state.page.doc, node); 706 831 707 832 if !was_focused { ··· 735 860 is.set_cursor(byte_pos); 736 861 } 737 862 } 863 + } else { 864 + // Other form controls (button, select, etc.) — just focus. 865 + state.page.doc.set_active_element(Some(node), false); 738 866 } 739 867 rerender(state); 740 868 } else { 741 - // Clicked outside any form control — blur. 742 - if state.page.doc.active_element().is_some() { 869 + // No form control hit. Check for label click delegation. 870 + let label_control = hit_test_any_element(&tree.root, view_x, view_y, 0.0, 0.0) 871 + .and_then(|hit_node| { 872 + find_ancestor_label(&state.page.doc, hit_node) 873 + .and_then(|label| state.page.doc.label_control(label)) 874 + }); 875 + 876 + if let Some(control) = label_control { 877 + activate_control(state, control); 878 + rerender(state); 879 + } else if state.page.doc.active_element().is_some() { 880 + // Clicked outside any form control or label — blur. 743 881 state.page.doc.set_active_element(None, false); 744 882 rerender(state); 745 883 } ··· 818 956 /// Hit-test the layout tree for a form control at the given coordinates. 819 957 /// 820 958 /// Returns `(NodeId, local_x_in_content, content_width, font_size)` if a 821 - /// text-editable form control was hit. 959 + /// form control was hit (any type: text input, checkbox, radio, button, etc.). 822 960 fn hit_test_form_control( 823 961 layout_box: &we_layout::LayoutBox, 824 962 x: f32, ··· 854 992 } 855 993 } 856 994 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 995 + // If this is any form control, return it. 996 + if layout_box.form_control.is_some() { 997 + if let we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) = 998 + layout_box.box_type 999 + { 1000 + let content_x = parent_x + layout_box.rect.x; 1001 + let local_x = (x - content_x).max(0.0); 1002 + return Some((node, local_x, layout_box.rect.width, layout_box.font_size)); 1003 + } 1004 + } 1005 + } 1006 + None 1007 + } 1008 + 1009 + /// Hit-test the layout tree for any element at the given coordinates. 1010 + /// 1011 + /// Returns the deepest `NodeId` whose layout box contains the point. 1012 + /// Used for detecting clicks on `<label>` elements. 1013 + fn hit_test_any_element( 1014 + layout_box: &we_layout::LayoutBox, 1015 + x: f32, 1016 + y: f32, 1017 + parent_x: f32, 1018 + parent_y: f32, 1019 + ) -> Option<NodeId> { 1020 + let bx = parent_x + layout_box.rect.x - layout_box.padding.left - layout_box.border.left; 1021 + let by = parent_y + layout_box.rect.y - layout_box.padding.top - layout_box.border.top; 1022 + let bw = layout_box.rect.width 1023 + + layout_box.padding.left 1024 + + layout_box.padding.right 1025 + + layout_box.border.left 1026 + + layout_box.border.right; 1027 + let bh = layout_box.rect.height 1028 + + layout_box.padding.top 1029 + + layout_box.padding.bottom 1030 + + layout_box.border.top 1031 + + layout_box.border.bottom; 1032 + 1033 + if x >= bx && x < bx + bw && y >= by && y < by + bh { 1034 + // Check children first (deepest wins). 1035 + for child in layout_box.children.iter().rev() { 1036 + if let Some(hit) = hit_test_any_element( 1037 + child, 1038 + x, 1039 + y, 1040 + parent_x + layout_box.rect.x, 1041 + parent_y + layout_box.rect.y, 864 1042 ) { 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 - } 1043 + return Some(hit); 872 1044 } 873 1045 } 1046 + match layout_box.box_type { 1047 + we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) => Some(node), 1048 + we_layout::BoxType::TextRun { node, .. } => Some(node), 1049 + we_layout::BoxType::Anonymous => None, 1050 + } 1051 + } else { 1052 + None 1053 + } 1054 + } 1055 + 1056 + /// Walk up the DOM from `node` looking for an ancestor `<label>` element. 1057 + fn find_ancestor_label(doc: &we_dom::Document, node: NodeId) -> Option<NodeId> { 1058 + let mut current = Some(node); 1059 + while let Some(id) = current { 1060 + if doc.tag_name(id) == Some("label") { 1061 + return Some(id); 1062 + } 1063 + current = doc.parent(id); 874 1064 } 875 1065 None 876 1066 }
+183
crates/dom/src/lib.rs
··· 8 8 9 9 pub mod input_state; 10 10 11 + use std::collections::HashSet; 11 12 use std::fmt; 12 13 13 14 use we_memory::intern::Atom; ··· 80 81 focus_visible: bool, 81 82 /// Per-element editing state for text inputs and textareas. 82 83 pub input_states: InputStateMap, 84 + /// Set of checkbox elements in the indeterminate state (visual only). 85 + indeterminate: HashSet<NodeId>, 83 86 } 84 87 85 88 impl fmt::Debug for Document { ··· 108 111 active_element: None, 109 112 focus_visible: false, 110 113 input_states: InputStateMap::new(), 114 + indeterminate: HashSet::new(), 111 115 } 112 116 } 113 117 ··· 568 572 current = self.parent(id); 569 573 } 570 574 false 575 + } 576 + 577 + // --- Indeterminate state --- 578 + 579 + /// Returns true if the checkbox is in the indeterminate visual state. 580 + pub fn is_indeterminate(&self, node: NodeId) -> bool { 581 + self.indeterminate.contains(&node) 582 + } 583 + 584 + /// Set or clear the indeterminate visual state for a checkbox. 585 + pub fn set_indeterminate(&mut self, node: NodeId, value: bool) { 586 + if value { 587 + self.indeterminate.insert(node); 588 + } else { 589 + self.indeterminate.remove(&node); 590 + } 591 + } 592 + 593 + // --- Radio group helpers --- 594 + 595 + /// Find all radio buttons in the same radio group as `node`. 596 + /// 597 + /// Radio buttons are in the same group if they share the same `name` attribute 598 + /// and are owned by the same `<form>` (or are both form-less). 599 + /// Returns all members including `node` itself. 600 + pub fn radio_group_members(&self, node: NodeId) -> Vec<NodeId> { 601 + if self.tag_name(node) != Some("input") { 602 + return vec![]; 603 + } 604 + let input_type = self.get_attribute(node, "type").unwrap_or("text"); 605 + if !input_type.eq_ignore_ascii_case("radio") { 606 + return vec![]; 607 + } 608 + let name = match self.get_attribute(node, "name") { 609 + Some(n) if !n.is_empty() => n.to_string(), 610 + _ => return vec![node], // nameless radios are their own group 611 + }; 612 + let form_owner = self.form_owner(node); 613 + 614 + let mut members = Vec::new(); 615 + for i in 0..self.nodes.len() { 616 + let candidate = NodeId::from_index(i); 617 + if self.tag_name(candidate) != Some("input") { 618 + continue; 619 + } 620 + let t = self.get_attribute(candidate, "type").unwrap_or("text"); 621 + if !t.eq_ignore_ascii_case("radio") { 622 + continue; 623 + } 624 + let cand_name = self.get_attribute(candidate, "name").unwrap_or(""); 625 + if cand_name != name { 626 + continue; 627 + } 628 + if self.form_owner(candidate) != form_owner { 629 + continue; 630 + } 631 + members.push(candidate); 632 + } 633 + members 571 634 } 572 635 573 636 /// Returns true if the element can receive focus. ··· 1535 1598 doc.append_child(root, text); 1536 1599 1537 1600 assert!(!doc.is_focusable(text)); 1601 + } 1602 + 1603 + // --- Indeterminate state tests --- 1604 + 1605 + #[test] 1606 + fn indeterminate_initially_false() { 1607 + let mut doc = Document::new(); 1608 + let input = doc.create_element("input"); 1609 + assert!(!doc.is_indeterminate(input)); 1610 + } 1611 + 1612 + #[test] 1613 + fn set_indeterminate() { 1614 + let mut doc = Document::new(); 1615 + let input = doc.create_element("input"); 1616 + doc.set_indeterminate(input, true); 1617 + assert!(doc.is_indeterminate(input)); 1618 + doc.set_indeterminate(input, false); 1619 + assert!(!doc.is_indeterminate(input)); 1620 + } 1621 + 1622 + // --- Radio group tests --- 1623 + 1624 + #[test] 1625 + fn radio_group_same_name_same_form() { 1626 + let mut doc = Document::new(); 1627 + let root = doc.root(); 1628 + let form = doc.create_element("form"); 1629 + let r1 = doc.create_element("input"); 1630 + let r2 = doc.create_element("input"); 1631 + let r3 = doc.create_element("input"); 1632 + 1633 + doc.set_attribute(r1, "type", "radio"); 1634 + doc.set_attribute(r1, "name", "color"); 1635 + doc.set_attribute(r2, "type", "radio"); 1636 + doc.set_attribute(r2, "name", "color"); 1637 + doc.set_attribute(r3, "type", "radio"); 1638 + doc.set_attribute(r3, "name", "color"); 1639 + 1640 + doc.append_child(root, form); 1641 + doc.append_child(form, r1); 1642 + doc.append_child(form, r2); 1643 + doc.append_child(form, r3); 1644 + 1645 + let members = doc.radio_group_members(r1); 1646 + assert_eq!(members.len(), 3); 1647 + assert!(members.contains(&r1)); 1648 + assert!(members.contains(&r2)); 1649 + assert!(members.contains(&r3)); 1650 + } 1651 + 1652 + #[test] 1653 + fn radio_group_different_names_not_grouped() { 1654 + let mut doc = Document::new(); 1655 + let root = doc.root(); 1656 + let form = doc.create_element("form"); 1657 + let r1 = doc.create_element("input"); 1658 + let r2 = doc.create_element("input"); 1659 + 1660 + doc.set_attribute(r1, "type", "radio"); 1661 + doc.set_attribute(r1, "name", "color"); 1662 + doc.set_attribute(r2, "type", "radio"); 1663 + doc.set_attribute(r2, "name", "size"); 1664 + 1665 + doc.append_child(root, form); 1666 + doc.append_child(form, r1); 1667 + doc.append_child(form, r2); 1668 + 1669 + let members = doc.radio_group_members(r1); 1670 + assert_eq!(members.len(), 1); 1671 + assert_eq!(members[0], r1); 1672 + } 1673 + 1674 + #[test] 1675 + fn radio_group_different_forms_not_grouped() { 1676 + let mut doc = Document::new(); 1677 + let root = doc.root(); 1678 + let form1 = doc.create_element("form"); 1679 + let form2 = doc.create_element("form"); 1680 + let r1 = doc.create_element("input"); 1681 + let r2 = doc.create_element("input"); 1682 + 1683 + doc.set_attribute(r1, "type", "radio"); 1684 + doc.set_attribute(r1, "name", "color"); 1685 + doc.set_attribute(r2, "type", "radio"); 1686 + doc.set_attribute(r2, "name", "color"); 1687 + 1688 + doc.append_child(root, form1); 1689 + doc.append_child(root, form2); 1690 + doc.append_child(form1, r1); 1691 + doc.append_child(form2, r2); 1692 + 1693 + let members = doc.radio_group_members(r1); 1694 + assert_eq!(members.len(), 1); 1695 + assert_eq!(members[0], r1); 1696 + } 1697 + 1698 + #[test] 1699 + fn radio_group_nameless_is_own_group() { 1700 + let mut doc = Document::new(); 1701 + let root = doc.root(); 1702 + let r1 = doc.create_element("input"); 1703 + let r2 = doc.create_element("input"); 1704 + 1705 + doc.set_attribute(r1, "type", "radio"); 1706 + doc.set_attribute(r2, "type", "radio"); 1707 + 1708 + doc.append_child(root, r1); 1709 + doc.append_child(root, r2); 1710 + 1711 + // Without a name, each radio is its own group. 1712 + let members = doc.radio_group_members(r1); 1713 + assert_eq!(members, vec![r1]); 1714 + } 1715 + 1716 + #[test] 1717 + fn radio_group_non_radio_returns_empty() { 1718 + let mut doc = Document::new(); 1719 + let div = doc.create_element("div"); 1720 + assert!(doc.radio_group_members(div).is_empty()); 1538 1721 } 1539 1722 }
+12
crates/layout/src/lib.rs
··· 105 105 pub disabled: bool, 106 106 /// Whether the control currently has focus. 107 107 pub focused: bool, 108 + /// Whether the checkbox is in the indeterminate visual state. 109 + pub indeterminate: bool, 108 110 /// Cursor byte offset into `value` (for text inputs/textareas when focused). 109 111 pub cursor: usize, 110 112 /// Selection anchor byte offset (equal to cursor if no selection). ··· 669 671 checked, 670 672 disabled, 671 673 focused, 674 + indeterminate: doc.is_indeterminate(node), 672 675 cursor: 0, 673 676 selection_anchor: 0, 674 677 }), ··· 678 681 checked, 679 682 disabled, 680 683 focused, 684 + indeterminate: false, 681 685 cursor: 0, 682 686 selection_anchor: 0, 683 687 }), ··· 692 696 checked: false, 693 697 disabled, 694 698 focused, 699 + indeterminate: false, 695 700 cursor: 0, 696 701 selection_anchor: 0, 697 702 }) ··· 707 712 checked: false, 708 713 disabled, 709 714 focused, 715 + indeterminate: false, 710 716 cursor: 0, 711 717 selection_anchor: 0, 712 718 }) ··· 719 725 checked: false, 720 726 disabled, 721 727 focused, 728 + indeterminate: false, 722 729 cursor: 0, 723 730 selection_anchor: 0, 724 731 }) ··· 737 744 checked: false, 738 745 disabled, 739 746 focused, 747 + indeterminate: false, 740 748 cursor, 741 749 selection_anchor: anchor, 742 750 }) ··· 756 764 checked: false, 757 765 disabled, 758 766 focused, 767 + indeterminate: false, 759 768 cursor, 760 769 selection_anchor: anchor, 761 770 }) ··· 777 786 checked: false, 778 787 disabled, 779 788 focused, 789 + indeterminate: false, 780 790 cursor, 781 791 selection_anchor: anchor, 782 792 }) ··· 791 801 checked: false, 792 802 disabled, 793 803 focused, 804 + indeterminate: false, 794 805 cursor: 0, 795 806 selection_anchor: 0, 796 807 }) ··· 809 820 checked: false, 810 821 disabled, 811 822 focused, 823 + indeterminate: false, 812 824 cursor: 0, 813 825 selection_anchor: 0, 814 826 })
+19 -1
crates/render/src/lib.rs
··· 1022 1022 color: bc, 1023 1023 }); 1024 1024 1025 + // Indeterminate indicator: horizontal dash in the center. 1026 + if fc.indeterminate { 1027 + let ind_color = if fc.disabled { 1028 + FC_DISABLED_TEXT 1029 + } else { 1030 + FC_CHECK_COLOR 1031 + }; 1032 + let inset = (size * 0.25).max(2.0); 1033 + let thick = (size * 0.15).max(1.5); 1034 + let dash_y = y + (size - thick) / 2.0; 1035 + list.push(PaintCommand::FillRect { 1036 + x: x + inset, 1037 + y: dash_y, 1038 + width: size - inset * 2.0, 1039 + height: thick, 1040 + color: ind_color, 1041 + }); 1042 + } 1025 1043 // Checkmark when checked: draw a simple check using two rectangles 1026 1044 // forming an "L" rotated, approximated as two thick lines. 1027 - if fc.checked { 1045 + else if fc.checked { 1028 1046 let check_color = if fc.disabled { 1029 1047 FC_DISABLED_TEXT 1030 1048 } else {